资讯详情

资讯详情

行业资讯

一文讲透值传递、地址传递与引用传递:内存视角与工程取舍

从初学函数到现在我见过太多人在三种参数传递方式上栽跟头。尤其是刚入行那会儿一个交换函数怎么都改不到外层变量整个下午耗在控制台里反复打印地址。后来带一个新人排查线上问题他对着 Java 的传参机制看了半天坚信自己写的是引用传递结果改了半天发现对象根本没被替换掉。这类问题的根源很清楚值传递、地址传递和引用传递这三者的边界大多数人在教材里看过定义却从来没把内存视图理明白。这篇文章我不打算复述教科书而是从实际排查和接口设计的角度把三条传递路径的底层逻辑、语言差异和工程取舍一次说透。适合刚接触 C/C 的初学者也适合写过不少业务代码却在 Java、Python、Go 之间来回横跳过的人。理解清楚之后你会在设计函数签名、排查“调用完没效果”的 Bug、以及评估性能开销时少走至少半年的弯路。1. 先厘清前提形参、实参与调用链的内存场景在讨论三种传递方式之前我们得先把函数调用那一刻到底发生了什么说清楚。很多困惑都来自“我把变量传进函数里了”这个笼统的说法但实际上编译器在函数调用点做的事远比你想象的要多。1.1 形参和实参并不是同一个“格子”当程序执行到某个函数调用时实参表达式的值是先被求值再交给被调函数。被调函数内部接收这些值的参数叫形参。这里的关键是形参存在于被调函数的栈帧里是独立的存储位置。实参和形参之间本质上是一次“值的复制”或者“地址的复制”取决于你传的是什么。我曾经跟一个学 C 的初学者解释这个概念他总认为在函数内部改了形参外面的实参就应该跟着变理由是“同一个变量嘛”。这就是对“格子”没有概念。你声明了一个变量a它在主调函数的栈帧里有一块空间你又把a传进函数被调函数的形参x在自己的栈帧里另起炉灶。两处代码里虽然写的都是同一个名字但内存地址完全不同除非你传的是a的地址或者显式地绑定引用。1.2 一个帮人建立内存直觉的模型我建议所有初学者把变量想象成“门牌号”加“柜子”的组合。变量名是门牌号标识位置变量的值是柜子里面的东西。值传递就是你拿着柜子里的东西复印了一份递给对方对方怎么处理复印单都影响不到你的原稿。地址传递是你把柜子的钥匙复印件递过去对方用钥匙打开你的柜子想怎么动就怎么动。引用传递相当于你把自己的门牌号直接给了对方让对方拿到的是这个地址本身的真身而不是复印件操作起来比钥匙还顺手而且语言层面对你隐藏了取地址操作。从这个模型延展下去很多问题就清晰了。比如“为什么在函数里交换两个数传出后没变”是因为你只是交换了复印单上的数字。又比如“为什么用指针就能变”是因为你通过钥匙改的是柜子里的实物。再比如 Java 里“为什么对象里的字段能改但重置对象引用没用”是因为对象引用是钥匙复印件透过钥匙能改柜子里的内容但把钥匙复印件掰弯了跟原来的钥匙无关。1.3 为什么不直接用一套理论套所有语言有人可能会问现代高级语言都提供了丰富的语义我们还要区分这三种方式干嘛原因很实际不同语言对这三种方式的暴露程度不一样。C 没有引用传递C 同时具备全部三种Java 只有值传递但按引用语义操作字段Python 是对象引用传递。如果你只会一门语言很容易带着那套习惯去理解其他语言然后就进入我开头说的那种排查状态——代码看着没问题跑起来结果不对一行行审下来逻辑都对最后才发现是传参语义理解错了。所以后面所有讨论我都统一基于“内存里的实体是什么、传入函数的是什么”这个底层视角。只要这一层通了语言差异只是语法糖的差异。2. 值传递、地址传递与引用传递的内存操作细节现在进入正题。我从 C/C 出发来讲清楚这三种方式因为这是语法上最实在的语言也最容易看出问题。为了直观我会用同一个任务——修改入参的值——来演示三种方式的不同效果。2.1 值传递副本隔离改完即弃先看 C 语言的经典例子void modify_value(int x) { x 100; // 修改的是形参 x也就是实参的一个副本 } int main() { int a 10; modify_value(a); printf(%d\n, a); // 仍然是 10 return 0; }这里modify_value(a)会把a的值拷贝到栈上给形参x。x 100修改的是拷贝后的那个局部变量。等函数返回这份拷贝直接作废外面的a纹丝不动。值传递的优点是有隔离性。函数内部怎么折腾都不会影响调用方便于构造纯函数和单元测试。缺点是当实参是大结构体时每次调用都要完整复制一份时间和内存开销都不小。比如这个例子typedef struct { char name[64]; double scores[1024]; } Student; void print_student(Student s) { /* 每次调用都复制 8K 以上的数据 */ }如果你不在意这一点等到某天在日志里发现这个函数被每秒调用上万次、CPU 时间全耗在 memcpy 上才会回头心疼。2.2 地址传递拷贝地址共享目标同样的功能改用指针void modify_pointer(int *x) { *x 100; // 通过地址访问实参的存储单元 } int main() { int a 10; modify_pointer(a); printf(%d\n, a); // 输出 100 return 0; }这里实参传的是a即a的地址。形参x是指针它自己也是一个独立的变量也有自己的存储空间但它存储的值是a的地址。所谓“地址传递”仍然是按值传递——把地址值拷贝了一份给形参。那为什么能修改外部变量关键在于你这回没有改“地址值”而是通过地址值去解引用访问它指向的那个柜子里的实物。这里有一个最容易混淆的点指针可以作为参数表达“按引用修改”的能力但它本质上是值传递的一种特例传的是地址值。你要真想改指针本身让它指向另一个对象那得传指针的指针或者用下面的引用传递。别小看这一条我在实际代码里见过太多人拿一个int *p进函数想把它重新指向某块内存结果函数里默默改了p的副本出函数后p还是老样子。2.3 引用传递别名即本体C 引入的引用可以把这个过程写得干净很多void modify_ref(int x) { x 100; // 直接操作实参本体 } int main() { int a 10; modify_ref(a); std::cout a std::endl; // 输出 100 return 0; }引用是实参的别名等价于在函数作用域内多了一个“指向同一些内存单元”的名字。它相比指针的优势在于你不用处理空指针、不用写解引用符号、语义上从一开始就清楚这里的修改一定会回流到外部。看代码的人一眼就知道这个接口是想改实参。但引用也不是没有代价。它的语法不够显式调用处看起来和值传递没有任何区别。有人在公司代码评审里为了避免误用干脆约定“看到出现在参数列表里想都不用想这个函数一定是要改实参的。”这个约定在团队协作里非常有用。2.4 三种方式的同台演示我把三种方式放在同一个 C 程序里对比方便你看清效果差异#include iostream using namespace std; void by_value(int x) { x 100; } void by_pointer(int *x) { *x 200; } void by_reference(int x) { x 300; } int main() { int a 1, b 1, c 1; by_value(a); by_pointer(b); by_reference(c); cout a b c endl; // 输出: 1 200 300 return 0; }你有没有发现值传递和另外两种在函数内部写起来都感觉“没什么区别”差别只在调用处。值传递调用最自然但函数内改了就白改地址传递调用处多一个取地址运算符函数内要解引用引用传递调用处零负担函数内也无感知。正因为引用太“透明”团队里反而更需要自律。3. 各语言的实际语义映射C、C、Java、Python、Go 各怀心思跨进当今的主流编程语言之后你会发现同一套内存逻辑换了说法极容易产生误解。这节我带你把最常见的几种语言挨个过一遍。3.1 C/C最直观也最危险的教材C 语言只有值传递。你要模拟“按引用修改”就得自己传指针。C 在此基础上增加了引用语法但它跟指针的底层机制完全一致只是语法封了一层。危险的地方在于数组参数会被编译器退化成指针。你写void f(int arr[10])函数里用sizeof(arr)算出来不是 40 字节而是指针大小 8 字节。这是无数新手困惑的来源也埋下了缓冲区操作越界的雷。C 还有右值引用和移动语义那是另一个话题但理解引用传递的内存本质之后你再去看右值引用就不会觉得玄学了。它们处理的都是“别名”或者“临时对象的转移”而不是“重新复制一份值”。3.2 Java只有值传递但很多人嘴里的“引用”其实是另一种东西Java 里对象变量存的是对象的引用但传参时传的仍然是这个引用的副本。也就是说你还是没法在函数里改变外部那个变量指向的对象。举一个非常典型的反例void changeString(String str) { str new; }调用方传进来的变量仍然是原来的字符串对象因为你在函数里只是把形参这个引用副本指向了字符串常量池里的new。这类问题常出现在实际业务里比如把列表作为参数传进去准备赋一个全新的列表回去结果外面的列表毫无变化而列表内个别元素的字段却可能在某个方法里被改得面目全非。原因就是你拿到的是引用的副本但透过它你能访问到原对象的内容。如果你听到“Java 既不是值传递也不是引用传递”这种玄乎的说法别被绕进去。正确的理解框架是实参存的是引用传参时拷贝的是引用值所以它是“对象引用的值传递”。Java 没有任何语法能让你像 C 那样直接导向“变量的真正地址”。3.3 Python所有变量都是对象的引用Python 的模式跟 Java 有些像但表达得更加彻底。Python 里变量本身没有“值”的概念你可以把它理解成名字绑定到一个对象上。传参时形参和实参指向的是同一个对象def modify(lst): lst.append(100) # 能生效因为列表对象变了 lst [1, 2, 3] # 不能生效只是把局部变量重新绑定到新列表 items [0] modify(items) print(items) # 输出 [0, 100]这个例子我在面试里问过很多人。lst.append(100)是在原列表上做修改当然能反映到外部但lst [1, 2, 3]只是让局部名字指向了一个新建对象外面的items依然指向原来的列表对象。很多教科书把 Python 这种语义叫“对象引用传递”也有人叫“共享传参”。关键其实是你在函数里对参数对象做原地操作会有副作用你对参数名重新赋值相当于只是“换了标签”跟调用方无关。3.4 Go值传递为底指针语法继承 C 基因Go 跟 C 一样所有传参都按值进行。但 Go 的指针没有-和*混用的烦恼统一用.访问字段语法上更干净。你传递结构体指针时也还是把一份指针地址拷贝到了函数内通过它修改外部对象。但你不能在一个函数里让调用方的某个指针变量重新指向完全不同的内存除非你传的是指向指针的指针或者使用接口加装箱的间接手段。看这段 Go 代码func changeName(u *User) { u.Name new // 生效通过地址改了原对象 } func changeUser(u *User) { u User{Name: new} // 不生效改的是指针副本本身 }changeUser的写法在实际开发里偶尔会出现。写的人觉得传的明明是指针为什么函数外面变量的指向没变答案就是老生常谈地址值也被拷贝了。你想在函数里完成“重新分配一个结构体并让调用方的变量指向它”正确的做法要么返回新指针再赋值要么传**User要么用*u User{...}来解引用赋值。为了让你一眼看明白我整理了一张对比表语言默认传参语义能否修改外部简单变量能否重置外部引用本身备注C值传递不能不能用指针模拟引用C值传递可显式用引用引用可以引用可以三种方式齐活Java对象引用值传递不可直接改基本类型不能可改对象内部状态Python对象引用传递不可直接改不可变对象不能可改可变对象内部状态Go值传递配合指针指针可以不能除非二级指针语法简洁但语义要留心这张表值得你收藏。每次你在语言之间切换先查一下“能不能改外部变量”、“能不能重置外部引用”几乎能避开八成传参 Bug。4. 工程视角接口设计里到底该怎么选理论清楚之后真正的工作难点在于写接口时做选择。我面试和带人时经常说函数签名怎么设计直接决定了这个函数好不好用、坑不坑人。这一节我把实际中踩过的坑和合理的取舍规则都列出来。4.1 选择标准看是否修改、是否大对象、是否允许空值先给一个简单的决策顺序。第一步问自己要不要在函数里修改外部变量的值。如果不要首选值传递。如果对象很大改为传引用或指针避免复制。第二步如果一定要修改外部变量C 里优先引用因为语义清晰C 和 Go 里用指针。第三步考虑函数能不能接受空值。C 指针可以是NULLC 引用不能绑空因此在“可能不传对象”的场景指针更合适但你也得在函数入口判空。拿一个真实的项目来说明。某个服务对外暴露了一个配置聚合函数负责把多个配置源合并到同一个结构体里。第一版我为了图方便使用值传递并返回新结构体每次调用都拷贝一个包含上千个字段的配置结构QPS 一高 CPU 直接报警。改成指针入参后数量级不到原来的十分之一。这个例子说明“方便”只是写的时候方便性能账单会在线上补回来。4.2 避坑手册这些 Bug 我在代码里见过的次数最多第一个坑是“交换函数不生效”。凡是写过一段 C/C 的人多半都踩过void swap_bad(int x, int y) { int tmp x; x y; y tmp; }它只交换了形参的副本调用方一点感觉都没有。正确写法是传指针或引用。这个问题在嵌入式面试里几乎是必考因为候选人既需要展示对内存的理解又得说明为什么 “用局部变量中转” 的思路本身没错错在交换的介质不对。第二个坑是“别名攻击”。你以为函数里操作的是自己这份数据实际上它和另一个模块共享了同一块内存。C 里一旦传入引用且没加const函数就可能对外部对象造成不可预期的修改。而你怎么也想不到有人在内部随手调了一个第三方函数那个函数内部又把同一个引用往上抛一个看似只读的服务接口实际把全盘配置改了个底朝天。第三个坑更隐蔽误解const的语义。void f(const int *p)表示不能通过这个指针修改目标但并不意味着目标本身在所有上下文里都不可修改。另一个引用线程或另一个指针完全可以绕过限制去改它。当你用“加不加 const 判定安全性”时要靠接口管理不能靠小小的const一劳永逸。第四个坑在动态语言里更常见——函数参数过多。Java 里传一个对象函数内部 10 个字段全在改调用方根本不知道哪些变更生效。这种“胖参数”设计让代码可读性和可维护性双双下降。我的经验是一旦一个函数靠参数往里传三个以上“要修改回去”的字段就该考虑封装成命令对象或者改用拆分函数了。4.3 一个可复现的排查案例之前有次协助排查一个崩溃问题。现象是服务在跑了几小时后某条数据记录忽然被改成了垃圾值重启后恢复过一阵子又坏。排查思路先怀疑并发但有人在关键函数里发现调用方传进了一个共享缓存里的结构体指针函数内部二话不说就按自己的格式改写该结构体。而另一个线程正在用同一个结构体的原格式做解析。到这里问题就清晰了这不是参数传递写错而是传递“可变共享对象”时没有在接口层做保护隔离。最终修复方案很简单函数签名改成按值传递或者内部在无人并发干扰时再深拷贝。这类问题极难一眼看出因为三处代码各自单看都不算错组合起来就产生数据竞争。这种案例教会我选参数传递方式不只是“这个函数要修改调用方数据”的问题还要考虑对象的所有权边界、并发访问和生命周期。如果对象是共享且被并发读取的能传值就传值如果对象太大必须传引用就得在文档注释里写明“本函数会原地修改”并且调用方要保证访问互斥。4.4 从可读性角度反推什么样的接口一眼就懂我给自己定了一套签名设计规矩分享给大家参考。如果函数只读入参C 用const T只表达“我不想复制也不想修改”这是性价比最高的选择。如果函数要输出或者修改入参C 用非const引用C 和 Go 用指针。调用处不需要再额外说明因为函数名本身或者邻近的注释会交代清楚。如果入参可能为空且语义是“可选”用指针如果永远不可能为空优先引用。如果函数需要返回新建对象直接返回值即可。现代编译器有返回值优化不需要非得用输出参数绕一圈。这些规矩看着简单但能统一团队风格。我们几个同事在代码评审中对参数传递方式有了争议时就搬出这套规则从“要不要修改”“能不能为空”“对象多大”三个角度一条条过绝大多数争论当场终结。5. 性能开销与优化看似无感的复制累积起来吃性能最后聊一个容易忽略的角度参数传递方式对性能的实际影响。很多初学者觉得值传递和引用传递只是语义区别开销能差多少差很多特别是对象大、函数调用频繁的时候。5.1 值传递的隐藏成本先看基本类型比如int、double、指针本身。它们在常见平台上是 4 到 8 字节一条mov指令就能复制性能差异几乎可以忽略。这也是为什么教科书里面不改外部变量就默认传值没有任何负担。但对象一大就变了。假设一个结构体包含 2 KB 的字节数组或几百个字段每次调用按值传递都要触发一次内存复制。一次两次无所谓每秒调用十万次就相当于每秒复制 200 MB。如果你这个函数内部只用了结构体的两个字段这 200 MB 全是纯浪费。我在某个模块里把void parse(Config c)改成void parse(const Config c)之后CPU 占用直接降了百分之十几日志里那些诡异的超时也因此消失。这种优化没有任何复杂的算法就是把一次多余的深拷贝省掉而已。在 Python 和 Java 这类引语言里其实也有类似问题不过表现不同。Java 引用传递本身只复制指向对象的引用不复制对象内容所以大对象的传参不会造成昂贵的结构体拷贝。但频繁创建包装对象、或者每次调用重新组装出参对象也会因为分配和 GC 带来性能开销。你要注意的并不是值传递和引用传递的表面写法而是真正在内存里发生了多少次对象分配。5.2 引用传递的安全成本引用传递省了拷贝但引入别名语义编译器在优化时可能要考虑别名带来的限制。某些场景下因为一个函数参数是指针或引用编译器无法放心地把中间结果放进寄存器必须在内存里保持同步可能略微影响性能。不过现代编译器的指针别名分析已经很成熟通常这点损失完全值得换取复制海量数据的节省。C 里还有个和值/引用相关的常见选择题返回类型。如果你返回一个局部大对象老式写法是“传入一个输出引用参数”让函数往里填避免返回对象的临时复制。但现代 C 有返回值优化RVO和移动语义返回值往往能被构造到调用方指定的位置不需要额外的拷贝。所以我的建议是除非实测证明输出参数有显著优势否则优先采用自然返回新对象的方式代码读起来明显更顺畅。5.3 一个辅助判断的小表格我整理了一张按场景判断的速查表平时写代码时可以对照参考场景推荐传参方式理由int、double等小型值类型只读值传递无复制负担语义简单大型结构体/类对象只读C 用const TJava/Python 直接传引用避免高额副本开销需要修改外部基本类型变量C 引用 / C 和 Go 指针保证改动回流需要重新分配并让调用方持有新对象返回新对象或用二级指针/引用避免直接改指针副本的沙雕行为参数可为空指针空值表达可选更明确共享可变对象并发读传值或深拷贝避免数据竞争这张表结合前面的实现细节基本可以应对日常 80% 的签名设计。剩下的 20%就属于业务特殊要求需要靠团队注释、文档和严格的 code review 来约束。写到这里我突然又想起当初调试那个“怎么改都不生效的交换函数”的下午。如果那时有人告诉我一句话我能省下三个小时“先别急着改代码用printf把形参和实参的地址都打出来看一眼它们在内存里是不是同一个格子答案自己就出来了。”希望这篇内容能让你少踩几个类似的坑。

准备好开启您的数字化征程了吗?

专业团队为您量身定制,让您的企业网站脱颖而出

立即咨询