ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

参数传递的两种方式:单向传递与双向传递原理及实战

2026/10/2 3:22:43 拓冰建站 浏览量
参数传递的两种方式:单向传递与双向传递原理及实战 参数传递这话题我估计每个写代码的都绕不开。我印象最深的是刚工作那会儿写了个 C 语言函数想交换两个变量的值结果主函数里纹丝不动。当时百思不得其解后来才知道这叫“值传递”也就是题目里说的“单向传递”。这几年回头看单向传递和双向传递不只是语言层面的概念它藏在 main 函数、前端框架、脚本调用、甚至并发编程的每个角落里。这篇东西我不打算写教科书就按我实际排查问题、写代码的经验来聊把这两种传递方式的原理、语言差异、实战场景和坑都捋一遍。1. 参数传递的本质先搞懂“谁在改谁”1.1 单向传递值传递函数拿到的只是一份复印件单向传递最常见的形态就是按值传递。调用函数的时候系统会把实参的值复制一份把副本交给函数内部的形参去用。这个时候函数里随便怎么折腾改的只是那副本外面的原始变量毫发无损。我习惯用复印件的例子来讲这个事儿。你去打印店把身份证复印件交给工作人员对方在复印件上写写画画你手里的身份证原件不会有任何变化。C 语言里最常见的例子就是交换两个变量void swap(int a, int b) { int tmp a; a b; b tmp; } int main() { int x 10, y 20; swap(x, y); printf(x%d, y%d\n, x, y); // 依然是 x10, y20 return 0; }我当年第一次跑这段代码的时候输出结果让我愣了半天。后来画了内存图才明白swap 函数里的 a 和 b 是 x 和 y 的“复印件”交换复印件当然改变不了原件的顺序。这种单向传递的设计不是偷懒而是有意为之。它最大的好处是安全——被调函数无法破坏调用方的数据你传一个数值进去对方不会把你的变量改得面目全非。同时它的代价也很直观每次调用都要拷贝数据如果传的是一个几百兆的结构体或对象拷贝成本就大了。1.2 双向传递引用传递把地址交给对方让对方直接改原件双向传递对应的是按引用传递或者说按指针传递。这时候函数拿到的不是值的副本而是变量本身或者它所在的内存地址。函数内部对形参的操作本质上就是在操作外部的原始数据。还是用交换函数来对比。改成指针之后void swap(int *a, int *b) { int tmp *a; *a *b; *b tmp; } int main() { int x 10, y 20; swap(x, y); printf(x%d, y%d\n, x, y); // 这次真的变成 x20, y10 了 return 0; }这才实现了真正的交换。因为传进去的是 x 和 y 的地址函数拿着地址直接找到了内存里的原始数据把值给改了。生活里的类比也简单你不是给对方复印件而是把自家保险柜的钥匙给了他。对方拿着钥匙去开柜子把里面的东西换了个位置你之后看到的就是被改动过的结果。双向传递的代价是没有了安全隔离函数内部一旦越界或者改错数据外部变量的状态就被污染了。但好处是高效不用拷贝整个数据只需传一个地址指针或者给一个引用。在处理大对象、构造复杂数据结构的时候这种传递方式几乎是必须的。1.3 两种方式并存本质是安全与效率的权衡聊到这里你可能已经感觉到单向传递和双向传递并不是随便发明的两种写法它们背后的核心矛盾是安全性和效率。如果只看安全全用值传递最省心谁也别想动谁的数据。但全用值传递在大数据场景下就完了——每次传递都复制一份内存和CPU开销都很猛。比如函数里要处理一个百万级的数组按值传递得先把整个数组复制一遍这谁能受得了。如果只看效率全用引用传递就完了函数之间你改完我改全局状态乱成一锅粥出 bug 的时候根本不知道是哪个函数动了谁的变量。所以现代语言的设计者都在“安全”和“效率”之间找平衡点。C 语言最直接全在你手里默认值传递、要改就自己声明指针Java 和 Python 则默认对象按引用语义处理但基本类型又按值传递混合策略。理解了这层权衡你就明白为什么有的函数参数写 const 限定为什么有的语言推荐用小对象按值、大对象按引用这些规则背后都是同一个逻辑。2. 不同编程语言怎么实现“单向”和“双向”2.1 C 语言指针是唯一的双向通道C 语言的参数传递非常直白默认清一色按值传递想要双向传递就必须显式传递地址用指针来接。很多初学者觉得指针难其实就是“地址”和“目标”的关系没厘清。声明int *p你心里要清楚p 本身存的是一个地址*p才是地址对应内存里的那个整数。函数形参int *a收到的是外部变量 x 的地址函数里写*a 100改的是 x 所在内存里的值。这里有个重要的经验想在函数里修改指针本身的指向就得传指针的指针。比如你想写个函数给外部指针 p 重新赋一个地址形参要写成int **pp不然函数里改的是 p 的副本p 也是一个变量它的值被拷贝了外部 p 根本不会变化。void alloc_memory(int **pp, int size) { *pp (int*)malloc(size * sizeof(int)); } int main() { int *arr NULL; alloc_memory(arr, 10); free(arr); return 0; }这里arr是 arr 这个指针变量的地址*pp malloc(...)修改的是 arr 内部存的值也就是让它指向一块新内存。如果只传int *arr函数里给形参赋新地址外部 arr 还是 NULL这种情况我见过不少人掉坑。2.2 C引用让双向传递更优雅C 除了兼容 C 的指针还引入了“引用”reference类型。形参声明int a意思是“a 是外部变量的别名”函数内部操作 a 就等于操作外部变量本身。语法上更简洁不需要每次都写*和。void swap(int a, int b) { int tmp a; a b; b tmp; } int x 10, y 20; swap(x, y); // 直接传变量名就行不需要取地址引用比指针安全的地方在于引用一旦绑定就不能改绑也不存在空引用。指针可以为空所以每次使用前要么判空、要么冒险信任传入的值。引用在底层本质上还是地址传递但语言层面帮你规避了空指针这类低级错误。C 里还有个常用的技巧叫const 引用。比如函数参数写const std::vectorint data它的含义是按引用传递避免拷贝但加了 const 之后禁止修改这就把“双向传递的能力”和“单向传递的安全性”结合起来了。从语义上看它支持函数内部读数据但绝不允许改外部对象这种写法在工程上非常常见我强烈建议你在 C 项目中多这么写。2.3 Java只有值传递但对象是“披着值传递外衣的引用”Java 跟 C/C 不一样很多人刚学的时候会被“Java 到底是值传递还是引用传递”这个问题折磨。先给结论Java 只有值传递。基本类型int、double、boolean传的是数值副本修改形参不影响实参。对象类型传的是“引用的副本”——所谓引用其实就是对象地址的包装这个地址本身被复制了一份但复制品和原件指向同一个对象。所以你在方法里执行param.setName(新名字)因为是沿着地址找到同一个对象去操作外部看到的对象被改了但如果你执行param new Object()只是让形参指向了一个新对象外部的引用变量依然指向原来的对象并没有被重新赋值。public void modify(StringBuilder sb) { sb.append( world); // 外部会感知因为操作的是同一个对象 sb new StringBuilder(new); // 外部不会感知形参重新指向了新对象 }很多面试题喜欢问这个核心就是看你能不能分清“改变对象内部状态”和“改变引用指向”这两个操作的区别。前者走的是双向传递的路径后者依然是值传递的行为。2.4 Python可变对象与不可变对象两种性格Python 的参数传递本质上是“对象引用传递”也常被称作“传对象引用”但它的实际表现取决于对象是可变还是不可变。不可变对象int、str、tuple表现得像值传递函数里重新赋值形参外面的变量不受影响。因为 Python 里那些类型没有“原地修改”的能力每次操作都生成新对象。可变对象list、dict、set表现得像双向传递函数里用.append()、d[key]value这类原地修改操作外部对象立刻发生变化。def add_item(items, new_item): items.append(new_item) # 外部 list 会多一个元素 def rebind(items): items [1, 2, 3] # 外部 list 不会变形参指向了新列表 my_list [] add_item(my_list, 10) print(my_list) # [10]最经典的坑是 Python 默认参数使用可变对象。def f(items[]):这种写法会导致多次调用共享同一个列表因为默认参数在函数定义时只被创建一次后续调用如果改动了它下次调用看到的还是被改动后的状态。这种问题我在代码评审里见过不止一次最好的做法是默认参数写None函数内部再创建一个新列表。Go、Rust 等语言也各有各的做法。Go 跟 C 有点像有指针但默认值传递Rust 则通过所有权机制把“可变借用”和“不可变借用”放到类型系统里从编译期杜绝了乱改数据的可能。思路都是那个思路只是语言层面的约束力度不一样。3. 实战场景中的参数传递不止函数调用这么简单3.1 main 函数参数程序启动时的“单向传参”如果说函数传参是“代码内部”的传递那 main 函数的参数就是“外部世界”往程序内部传递信息的通道。C 和 C 的 main 函数签名通常是int main(int argc, char *argv[])其中 argc 是参数个数argv 是参数字符串数组。你在命令行敲./myprog --port 8080的时候编译器会把./myprog、--port、8080分别放进 argv[0]、argv[1]、argv[2]。这种传递是典型的外部到内部的单向传递程序启动后通常不会再反向去改命令行的内容。实操的时候我有两个习惯第一个习惯是永远从 argv[1] 开始解析别把 argv[0] 当默认参数。argv[0] 是程序路径不是用户输入有人写解析逻辑的时候手滑从 0 开始结果第一个合法参数被当成程序名处理了。第二个习惯是先校验 argc 再访问 argv。如果用户什么都没传argc 可能只有 1你直接访问 argv[1] 就是越界轻则读到脏值重则直接崩溃。所以早期的健壮性校验非常重要if (argc 2) { fprintf(stderr, Usage: %s config_file\n, argv[0]); return 1; }不要嫌这步啰嗦我见过线上程序因为少了一个参数就抛异常退出日志里只有一行可能不够用的提示排查成本远比你多写两行判断高得多。3.2 Python 脚本间传参从 sys.argv 到 argparse写 Python 的人经常会遇到一个需求有一个主脚本要调用另一个脚本并且要把参数传过去。最基础的方式就是通过 sys.argv 接收命令行参数然后在主脚本里用 subprocess 或 os.system 拼接命令传递。import sys import subprocess # 接收外部传入的数据 input_file sys.argv[1] output_file sys.argv[2] # 调用另一个脚本把这个脚本收到的参数继续往下传 subprocess.run([python, process_data.py, input_file, output_file])这种方式的优点是直观、可控缺点是要手动管理参数的地图和校验。参数一多拼接命令就容易脏比如文件名里有空格就会被误拆包含特殊字符还可能导致命令注入。所以项目稍微复杂一点我建议对方用 argparse 构建参数解析器import argparse parser argparse.ArgumentParser(description数据处理脚本) parser.add_argument(--input, requiredTrue, help输入文件路径) parser.add_argument(--output, requiredTrue, help输出文件路径) parser.add_argument(--batch-size, typeint, default64, help批次大小) args parser.parse_args()argparse 自带类型校验、默认值、必填检查、帮助信息生成这些能力能省掉一多半手工处理参数的工作量。而且你解析出来的参数直接放到一个 Namespace 对象里后续传给业务函数也就方便了很多。实际操作中我还有一个体会如果一个脚本需要很多参数与其在命令行里堆一长串不如把参数写成 JSON 或 YAML 配置文件命令行只传一个--config config.yaml。这样配置集中管理参数传递的链路更短出问题也容易定位。这个思路本质上就是“参数从分散到收敛”的优化。3.3 前端与框架中的参数传递Vue 的 props 和 v-model参数传递不只是命令行和函数的事前端框架里的组件通信同样是单向/双向传递思想的具体呈现。拿 Vue 举例父组件向子组件传数据用的是 props官方明确定义了单向数据流props 是只读的子组件不能直接修改 props。这个设计不是故意限制你而是要保证数据流向可预测。你要是在子组件里直接改了父组件传下来的 props页面上多个组件共享同一份数据时改动的来源就找不到是谁了调试成本非常高。如果子组件确实需要修改父组件的数据Vue 的正规做法是$emit(update:xxx, newValue)让父组件自己监听事件并更新数据或者直接用v-model语法糖实现双向绑定。!-- 单向props 传入展示 -- ChildComponent :titlepageTitle / !-- 双向v-model 绑定 -- ChildComponent v-modelpageTitle /v-model 的出现让写起来方便了但它本质上是语法糖背后仍然是“子组件触发事件、父组件更新数据”的单向数据流。所以在框架层谈双向传递不要以为数据能像魔术一样凭空双向往返它依然是事件机制和回调函数的封装。组件复杂的项目里props 嵌套层数太深会很难维护这就是为什么有 Vuex、Pinia、Redux 这类全局状态库。集中式的状态管理把共享参数抽到一个 store 里避免层层传递导致中间组件被迫转发跟自身无关的数据——这个经验对用过一段时间 Vue 或 React 的人应该深有体会。3.4 框架中的回调与信号槽跨线程或跨模块的传参Qt 的信号槽机制也是传参的一个经典代表。信号携带参数槽函数接收参数信号发射的时候参数会被拷贝到接收方。如果你用的是 Qt 的直连模式且双方在同一线程传递参数本质上类似于指针引用效率很高如果用了队列连接或跨线程参数会被拷贝后排队等待槽函数执行。这里有个我踩过好几次的坑用 Qt 信号槽传参如果传的是对象地址比如obj跨线程时一定要确认这个对象生命周期是否安全。信号发射了但槽函数可能在另一个线程稍后才执行如果这个对象在主线程被提前销毁槽函数里就是访问悬空指针程序崩溃得莫名其妙。一个稳妥的替代做法是传值或者传智能指针、共享封装类虽然多了一次拷贝但换来的是生命周期安全。实际项目中我宁愿多拷贝几字节也不愿意去赌执行的时序。类似的情况在 Node.js、消息队列里也都常见。微服务之间传一个 requestId 或 traceId本质上也是从一个模块把参数单向或双向地传到另外一个模块只不过传递的介质变成了网络协议和消息体。信息丢失、参数格式错位、数据顺序变更这些问题跟函数传参里遇到的坑如出一辙。4. 参数校验与防御性编程双向传递最容易翻车的地方4.1 非法参数与空值检查如果参数是单向传递的函数内部乱改数据最多是“自己给自己一个错误结果”如果参数是双向传递的函数里改了外部数据但校验不通过外部的状态可能已经被破坏了。我见过一个线上事故某个模块接受用户传入的列表函数内部没有判空就直接遍历结果传 null 进来程序抛了个空指针异常。日志里记录的是异常和堆栈但用户侧只看到 500 错误排查了很久才发现是一个参数没传导致的。所以我现在写双向传递的函数第一条铁律就是入口必须校验空值和非法值。不管是 C 语言的指针是否为 NULL还是 Java/Python 的对象是否为 None都要先检查再操作。int process_data(int *data, int size) { if (data NULL) { return -1; } if (size 0) { return -2; } // 正常处理逻辑 return 0; }不要觉得这是小题大做。很多编程语言里函数被设计成“内部信任外部传入参数合理”但外部调用时总会有人因为各种原因漏传、错传或传了边界值。宁可函数内部多几行防御代码也不要让一个非法参数像病毒一样把模块的状态全部污染。4.2 确认“你改的是副本还是原值”我每次做代码评审几乎是必问的一个问题“你这个函数会修改调用方传入的数据吗如果会调用方的预期是什么”有些函数为了性能内部直接对传入的集合进行排序或过滤结果调用方后续还要基于原始顺序做处理一下子就乱了。这种问题尤其常见于自定义工具函数被多处复用的场景。这个函数第一次被调用时参数是单向语义的调用方以为传了个副本不会担心但实现里用了双向语义的原地修改两边的信息不对称就埋下了雷。所以一个工程习惯是如果函数设计成只读的形参尽量用 const 或不可变对象如果确实要原地修改那函数命名就明确一点比如sort_in_place而不是sort。我见过有人把“原地排序函数”命名为sort_data然后别处误以为是返回新列表结果惨不忍睹。这个问题的本质不是语言不支持单向传递而是人和人之间的契约没有对齐。写清楚参数的读写语义比写一万行注释都有用。4.3 深拷贝与不可变数据双向传递有时候确实不需要。比如你明确知道自己要把一个列表传入函数做异步处理如果函数拿着这个列表的另一份引用等异步任务真正执行时列表已经被主流程改掉了就会得到莫名其妙的结果。这时你可能希望传一个深拷贝进去让函数在数据的独立副本上折腾。浅拷贝和深拷贝的区别经常是 bug 的源头。浅拷贝只复制最外层的对象内部嵌套的 list 或 dict 还是共享的深拷贝则把整个对象树完整复制一遍彻底隔离数据。import copy original {data: [1, 2, 3], meta: {version: 1.0}} shallow copy.copy(original) shallow[data].append(4) # original[data] 也会变成 [1,2,3,4] deep copy.deepcopy(original) deep[data].append(5) # original[data] 不受影响深拷贝的代价是性能和内存但在数据不被意外修改这件事上它就是最稳妥的保险。什么时候用深拷贝呢我自己的判断标准是数据规模不大几百KB以下、修改风险高多个模块共享、异步或跨线程场景直接深拷贝一个副本传过去省心。还有一种更本质的解法是采用不可变数据结构。Rust 的所有权、Java 里大量使用的record和不可变集合、Python 里用tuple和frozenset都是为了让参数在传递过程中天然没有被修改的空间。不可变性把“双向传递”里那个危险的方向直接堵死了能挡住一整类问题。5. 常见问题与排查技巧实录5.1 “函数里改了外面没变”的排查思路这个是我见过最多的求助帖主题尤其是刚接触 C 语言和 Java 的新人。“为什么我把数组传进函数函数里改了主函数里打印还是老样子”第一步先确认语言的传递规则看对象的类型是可变的还是不可变的。第二步在函数入口打日志或打印地址确认函数拿到的内存地址跟调用方的是不是同一个。第三步确认你的“修改”到底是重新绑定引用还是原地修改对象里的数据。回到 Java 的例子你可能写了这种代码public void addItem(ListString items) { items new ArrayList(); items.add(hello); }结果外部传入的 list 依然是空的。原因跟上面说的一样items new ArrayList()只是在函数内部把形参指向了新对象外部的引用变量指向的还是原来的空列表。正确写法是public void addItem(ListString items) { items.add(hello); }这类问题用一句话概括你改的是盒子里面的东西还是盒子本身。分清这两者大部分“改了没变”的问题都能自查出来。5.2 并发环境下参数被“串台”的问题参数传递在并发场景里格外容易出问题。我印象最深的是之前维护过一个网关模块多线程处理请求每个线程要带着一个请求 ID 往下传递。刚开始用了一个全局静态变量存这个 ID结果并发一上来A 请求还没处理完B 请求就把全局 ID 覆盖了日志里的 requestId 全是错乱的。这类问题的根因跟参数传递直接相关全局变量本质上就是一个无处不在的“双向传递通道”谁都能改改了就影响所有人。并发场景下参数必须跟请求绑定每个请求要有自己独立的上下文。线程之间传参Java 可以用 ThreadLocalGo 用 context.Context 把元数据一路传下去Python 可以用 contextvars。这些工具的核心思想都是一样的把参数放进一条独立的传递链里不让它跟并发的其他执行流搅在一起。如果你在用线程池还有一层隐藏风险复用线程时 ThreadLocal 里的旧数据会被新任务读到必须及时清理。不清理就会出现参数“残留”任务 B 莫名其妙看到了任务 A 留下的值排查起来非常诡异。我用 ThreadLocal 的习惯是 finally 里必须 remove。还有一种情况是异步任务提交时参数没有拷贝。提交到线程池的 Runnable 里捕获了外部变量这些变量在外面的循环里继续变化等真正执行时读到的可能是最后一个值。这种情况要么提交前做深拷贝要么把参数变成不可变对象否则定时器、异步任务里突然出现一堆相同的数据是非常正常的现象。5.3 参数太多怎么办用结构体或配置对象打包单看“单向传递”和“双向传递”很多人可能觉得一个函数传三五个参数就行了。但实际项目里业务复杂时一个函数十几二十个参数的情况屡见不鲜。调用的时候代码里一行全是true, false, 0, default, 100, 200谁能看懂这些参数分别控制什么顺序错乱、漏传、误传的概率成倍上升。我的建议是永远不要回避“参数收敛”这件事。C 语言里可以定义结构体把相关参数打包typedef struct { int port; int max_connections; int timeout_ms; char *log_file; } ServerConfig; int start_server(ServerConfig *config);C/Java/Python 里可以定义配置类或使用具名参数。Python 有**kwargs和 dataclassJava 有 Builder 模式目的都是让参数的含义和默认值清晰化。参数打包的好处不只是调用时好看更重要的是它改变了传递语义你传一个配置结构体的指针/引用函数内部既能读各项配置也能通过这个结构体把修改后的结果传回来本质上是把一堆单向传递的标量参数整合成一个双向传递的结构化参数。参数的可维护性一下子就好很多。这些经验不光用在日常编程里你写脚本、配框架、调接口也是一样的道理。比如你写一个运维脚本一堆开关参数在命令行上堆着与其让人去背诵每个参数含义不如给个--config参数指定一个配置文件把整个“参数组”作为一个整体传出去。5.4 参数校验失败的场景null、空字符串与类型错乱Java 和 C# 里最常见的运行时异常就是NullPointerException和IllegalArgumentException。你调用框架接口时很多时候明明只是漏传了一个参数运行时就抛异常原因就在参数没被正确校验。我的经验是把参数校验分为三层第一层是语法校验类型对不对、是不是 null、字符串是不是空。这里的“空”要仔细定义和 在很多场景下含义不同必要时 trim 之后再判断。第二层是范围校验数字是否在允许区间内列表大小是否超过上限日期是否合法。比如端口号必须是 1-65535负数直接拒绝。第三层是业务校验参数之间的组合关系是否合理。比如分页参数里 page 和 pageSize 要同时有效启用缓存时会话时间不能为负。这三层都过了才开始真正的业务逻辑。调试过多年的经验让我越来越确定把校验放在最前面比在业务代码里到处防御要高效得多。只要入口把关严格下游就少了很多脏数据处理的负担。注意写公共库或者框架代码时不要默默吞掉非法参数。该抛异常就抛异常该返回错误码就返回错误码让调用方第一时间感知到“参数不对”远比在业务深处产生一个难以理解的结果要好排查。5.5 参数传递方向的调试技巧打印、断点与日志排查参数传递问题我最常用的三个工具分别是 print/打日志、断点查看、以及浏览器的网络面板。最简单的办法在函数入口打印参数地址C/C 打指针值Java/Python 打印 id在函数出口再打一次对比它们是否一致。如果地址不同那就是值传递如果相同就是引用传递。打印 id 这个操作成本极低却能把“改没改到同一个对象”这个疑案直接判定。遇到复杂的调用链光靠 print 就不够了。用调试器的断点功能在关键调用处停下查看调用栈里每一层的实参和形参的值这时候参数是在哪一层丢的、在哪一层被改的一目了然。我处理过很多类似“参数明明是传了后面就没了”的 bug最后都是靠看调用栈和每层变量快照定位到问题的。前端场景里特别是框架间传参打开浏览器开发者工具的 Network 面板和组件调试工具能直接看到父组件往子组件传的 props 值以及子组件 emit 的事件携带的参数。很多前端参数的“随风飘散”问题在这里看一眼就明白了。还有一个隐藏的调试原则参数传递问题不要在接收方一侧反复猜要顺着传递链往源头查。从输出端开始打印每一步的参数快照找到第一个“值变了”或“值丢了”的位置那一刻就是 bug 所在的函数或组件。写在最后聊了这么多核心其实就是一句话你要清楚你传给函数的是一个“复印件”还是“原件地址”。单向传递保安全但费资源双向传递省效率但风险高真正的高手不是非此即彼而是知道什么时候该单向、什么时候该双向以及怎样用 const、深拷贝、不可变数据这些工具把两种模式的优点结合到一起来用。我自己的经验是写代码的时候多问一句“这个参数能被修改吗传出去之后生命周期有多长”很多隐蔽的 bug 在写下那行代码之前就能被消灭掉。参数传递这门手艺看起来是语言基础实际上渗透在系统设计的每一个角落。希望这篇总结能帮你少踩几个坑多省几杯咖啡的时间。