☰
一文讲透值传递、地址传递与引用传递:内存视角与工程取舍
2026/10/16 3:20:06 网站建设 项目流程

从初学函数到现在,我见过太多人在三种参数传递方式上栽跟头。尤其是刚入行那会儿,一个交换函数怎么都改不到外层变量,整个下午耗在控制台里反复打印地址。后来带一个新人排查线上问题,他对着 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 指针可以是NULL,C++ 引用不能绑空,因此在“可能不传对象”的场景,指针更合适,但你也得在函数入口判空。

拿一个真实的项目来说明。某个服务对外暴露了一个配置聚合函数,负责把多个配置源合并到同一个结构体里。第一版我为了图方便,使用值传递并返回新结构体,每次调用都拷贝一个包含上千个字段的配置结构,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 T&,Java/Python 直接传引用避免高额副本开销
需要修改外部基本类型变量C++ 引用 / C 和 Go 指针保证改动回流
需要重新分配并让调用方持有新对象返回新对象,或用二级指针/引用避免直接改指针副本的沙雕行为
参数可为空指针空值表达可选更明确
共享可变对象,并发读传值或深拷贝避免数据竞争

这张表结合前面的实现细节,基本可以应对日常 80% 的签名设计。剩下的 20%,就属于业务特殊要求,需要靠团队注释、文档和严格的 code review 来约束。

写到这里,我突然又想起当初调试那个“怎么改都不生效的交换函数”的下午。如果那时有人告诉我一句话,我能省下三个小时:“先别急着改代码,用printf把形参和实参的地址都打出来,看一眼它们在内存里是不是同一个格子,答案自己就出来了。”希望这篇内容能让你少踩几个类似的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询