第 24 章 复印还是原件——值传递与引用
🏗️ 前情回顾:函数能接收参数了——但传进去的到底是复印件还是原件?为什么上章末尾
addTen(a)调用后a还是 20?老师说加个&就能改——这&到底在内存里干了什么?这一章,我们把"参数如何传入函数"这个问题彻底讲透。
🎯 本章目标
学完这一章,你能:
- 从内存角度理解值传递和引用传递的本质区别
- 熟练使用
&写出能修改实参的引用传递函数 - 使用
const &实现"只读高效传参" - 根据场景判断该用值传递还是引用传递
- 写出正确的
swap交换函数,并能解释两种写法为什么一个行一个不行 - 理解数组作为参数时"退化成指针"的特性
- 避开忘记
&、误改原变量等常见陷阱
📖 故事引入
小明和小红是同桌。有一天他们互相检查作业:
方案 A:小明把作业复印了一份给小红。小红在复印件上批改、划线、做标记。小明的原件呢?完好无损——复印件上的任何改动都影响不到原件。
方案 B:小明直接把原件递给小红。小红在上面写写画画……等拿回来,原件已经面目全非。
这就是值传递和引用传递最核心的区别。在 C++ 中,每当你调用一个函数,你都在做类似的选择:是把变量的副本传进去,还是把变量本身传进去?
上一章你已经看到了表象——加个 & 就能"修改成功"。这一章,我们钻到内存里看看,这些数字到底是怎么流转的。
🧱 知识讲解
24.1 值传递:拷贝一份,各走各路
C++ 默认的参数传递方式是值传递。这意味着:调用函数时,形参会得到一份实参的完整副本。函数内部操作的是副本,和原变量毫无关系。
❌ 最常见的新手误解——"我明明在函数里改了呀!"
cppvoid change(int x) { x = 999; } // 以为能改 int main() { int a = 10; change(a); cout << a; // 还是10——为什么?! }因为
change拿到的是复印件。你在复印件上写"999",原件上的"10"纹丝不动。
✅ 我们用一段代码来验证背后发生了什么:
void tryChange(int x) {
cout << "函数内:x 的地址 = " << &x << endl; // 看看地址
x = 999;
}
int main() {
int a = 10;
cout << "main 里:a 的地址 = " << &a << endl;
tryChange(a);
cout << "调用后 a = " << a << endl; // 10——没变!
return 0;
}运行结果类似:
main 里:a 的地址 = 0x7ffd1a3b4c1c
函数内:x 的地址 = 0x7ffd1a3b4bec
调用后 a = 10注意看:两个地址不一样!a 和 x 住在内存的不同位置。x = 999 只是修改了 x 那块内存,a 那块内存纹丝不动。
内存模型(值传递):
调用前: a ─── [10] (地址: 0x4c1c)
调用时: a ─── [10] (地址: 0x4c1c) ← 原变量,不改
x ─── [10] (地址: 0x4bec) ← 副本,改这里
x = 999: a ─── [10] (不变!)
x ─── [999] (副本变了)💡 类比:就像游戏里的"存档"。你复制了一份存档发给朋友,朋友在他的电脑上玩得热火朝天——你自己的存档进度还是老样子。两份存档是独立的。
什么时候用值传递?当参数是 int、char、double 这类小数据,且你不需要修改原变量时,值传递最安全、最直接。
24.2 引用传递:直接操作原变量
在参数类型后面加一个 &,就成了引用传递。引用不是副本——它是原变量的别名。修改引用,就是修改原变量。
void tryChange(int &x) { // 注意这个 &
cout << "函数内:x 的地址 = " << &x << endl;
x = 999;
}
int main() {
int a = 10;
cout << "main 里:a 的地址 = " << &a << endl;
tryChange(a);
cout << "调用后 a = " << a << endl; // 999——真的变了!
return 0;
}运行结果:
main 里:a 的地址 = 0x7ffd1a3b4c1c
函数内:x 的地址 = 0x7ffd1a3b4c1c
调用后 a = 999这一次,两个地址完全一样!x 根本不是新创建的变量,它只是 a 的另一个名字。
内存模型(引用传递):
调用时: a/x ─── [10] (同一个地址!)
x = 999: a/x ─── [999] (直接改原内存)💡 类比:就像你给文件创建了一个快捷方式。无论是双击快捷方式还是双击原文件,打开的都是同一个文件。引用的关系就是"快捷方式→原文件"。
关键语法:
- 函数定义:
void func(int ¶m)— 声明这里是引用 - 函数调用:
func(a)— 直接传变量名,不需要额外的& - 调用时不能传字面量:
func(5)❌ — 5 没有地址,无法引用
24.3 const 引用:只看不碰
有时候你希望享受引用传递的效率(不用复制),但又不想函数"手贱"修改你的数据。这时候用 const 引用——用 & 避免复制,用 const 锁住权限。
// 只想打印,不想修改——加 const 保护
void show(const string &s) {
cout << "内容是:" << s << endl;
// s = "hello"; // ❌ 编译错误!const 不允许修改
}
int main() {
string name = "Alice";
show(name); // ✅ 高效:不复制整个字符串
show("Bob"); // ✅ const 引用可以绑定字面量(临时对象)
return 0;
}为什么 const 引用这么常用?
想象传一个 10KB 的字符串给函数:
- 值传递:
void show(string s)→ 复制整个 10KB 字符串,慢! - const 引用:
void show(const string &s)→ 只传一个"指向原数据的引用",快!
// 推荐写法 —— 高效且安全
void process(const vector<int> &data) {
cout << "数据大小:" << data.size() << endl;
// 只读 data,绝不修改
}🧠 记忆口诀:
const &= "借来看,不借走"——效率高,又不怕误修改。
24.4 何时用值、何时用引用?决策指南
| 场景 | 推荐方式 | 原因 |
|---|---|---|
参数是 int、char、double 等小类型,不需要修改 | 值传递 | 复制成本极低 |
| 需要函数修改参数的值 | 引用传递 & | 必须直接操作原变量 |
参数是大对象(string、vector),只读 | const & | 避免复制大对象,加了只读保护 |
| 参数是数组 | 自动退化为指针(见 25.6) | 数组永远不会被值传递复制 |
示例:综合运用
int square(int x) { return x * x; } // 小类型 → 值传递
void swap(int &a, int &b) { /* ... */ } // 要修改 → 引用
void print(const string &msg) { /* ... */ } // 大对象只读 → const 引用24.5 经典对比:swap 的两种写法
没有比 swap(交换两个变量的值)更能说明值传递和引用传递区别的例子了。下面两种写法,只有一种能成功。
写法一:值传递(失败版)
void swapWrong(int a, int b) { // 值传递——拿到的是副本
int temp = a;
a = b;
b = temp;
cout << "swap内:a=" << a << " b=" << b << endl; // 副本交换了
}
int main() {
int x = 10, y = 20;
swapWrong(x, y);
cout << "main内:x=" << x << " y=" << y << endl; // 10 20 —— 没变!
return 0;
}swap内:a=20 b=10 ← 副本确实交换了
main内:x=10 y=20 ← 但原变量纹丝不动!写法二:引用传递(成功版)
void swapRight(int &a, int &b) { // 引用——直接操作原变量
int temp = a;
a = b;
b = temp;
}
int main() {
int x = 10, y = 20;
swapRight(x, y);
cout << "x=" << x << " y=" << y << endl; // 20 10 —— 成功了!
return 0;
}你可以把这两种写法都敲进编译器里跑一遍,亲自感受它们的差异——加不加 &,天差地别。
24.6 数组作参数:默认传地址
数组是个"特殊公民"。当你把数组作为函数参数时,它从来不会走值传递——编译器会悄悄把它"退化"成一个指向首元素的指针。
void fillArray(int arr[], int n) { // arr 实际上是个 int* 指针
for (int i = 0; i < n; i++) {
arr[i] = i * 10; // 直接修改原数组!
}
}
int main() {
int a[5] = {0};
fillArray(a, 5); // 传数组名 = 传首地址
for (int i = 0; i < 5; i++)
cout << a[i] << " "; // 0 10 20 30 40 —— 原数组被改了!
return 0;
}背后的真相:void fillArray(int arr[], int n) 被编译器理解为 void fillArray(int *arr, int n)。arr 是一个指针,指向 main 里数组 a 的起始位置。修改 arr[i] 就是修改 a[i]。
这就是为什么很多函数需要额外传一个参数表示数组长度(上面的 n)——因为 arr 只是一个指针,它记不住数组有多长。
// 等价写法 —— 下面这两种声明完全一样
void func(int arr[], int n);
void func(int *arr, int n); // 显式写成指针更诚实24.7 常见陷阱一览
陷阱 1:忘记 &,修改无效
这是新手最常见的错误——以为函数能修改变量,结果什么都没改变。
❌ void addBonus(int score) → 函数里 score += 10,出来以后还是原值
✅ void addBonus(int &score) → 别忘了 &
陷阱 2:想传 const & 却写成了值传递
❌ void countVowels(string s) → 每次调用都复制一遍整个字符串,白白浪费性能
✅ void countVowels(const string &s) → 不复制,还防误改
陷阱 3:试图给引用传字面量
❌ void addTen(int &x) 然后调用 addTen(5) → 编译错误!字面量 5 没有地址
✅ 如果确实要接受字面量,改成 const int &x,或改回值传递
陷阱 4:引用传递后误改了不想改的变量
❌ 不小心把只读参数声明成了普通引用,函数里意外修改了调用者的数据
✅ 只要不需要修改,就加 const——让编译器帮你把关
陷阱 5:数组参数里写了数组大小
❌ void print(int arr[10]) → 编译器会忽略 10 这个数字,arr 还是指针
✅ 正确做法:void print(int arr[], int n),把长度作为单独的参数传入
✋ 动手试试
试试 1:写一个 increment(int &x) 函数,它接收一个引用参数,把参数加 1。在 main() 里调用它 3 次,观察变量的变化。
试试 2:写一个 reset(int &a, int &b) 函数同时把两个参数置零。调用它验证效果。
试试 3:写一个 reverse(int arr[], int n) 函数,翻转数组。只传数组名和长度,不返回任何值——观察 main() 里的数组是否真的被翻转了。
试试 4:对比三种传参方式,写三个版本的函数分别用值传递、引用传递、const 引用传递。在每个函数里尝试修改参数,看编译器会报什么错。
🦶 你踩过这些坑吗
逐一核对,看你是不是也中过招:
- [ ] swap 写成了值传递:
void swap(int a, int b)→ 调用后原变量纹丝不动,这是踩坑人数最多的错误之一 → ✅void swap(int &a, int &b) - [ ] 给普通引用传字面量:定义了
void show(int &x),调用时写show(42)→ ❌ C++ 不允许字面量绑定到非 const 引用 → ✅ 改用void show(const int &x) - [ ] 以为数组参数会复制:写
void init(int arr[100])并期望函数收到一个独立的数组副本 → ❌ 数组不会复制,arr就是原数组 → ✅ 想保护原数组需手动在函数里创建副本 - [ ] 数组参数写了长度却不用额外参数传:
void print(int arr[5])→ 编译器忽略 5,arr 不知道自己的长度 → ✅void print(int arr[], int len)
📝 练习
基础题
1. 选择题
(1)C++ 中默认的参数传递方式是?
A. 引用传递 B. 值传递 C. 指针传递 D. 地址传递
(2)要让函数能够修改传入的实参,应该使用?
A. 值传递 B. 引用传递(&) C. void 返回 D. 全局变量
(3)以下关于 const 引用的说法,错误的是?
A. 避免复制大对象 B. 函数内不能修改参数 C. 可以绑定字面量 D. 函数内可以修改参数
2. 填空题
(1)值传递时,形参是实参的_____;引用传递时,形参是实参的_____。
(2)void swap(int &a, int &b) 中 & 的作用是让 a 和 b 成为_____传递参数。
(3)数组作为函数参数时,实际上传递的是数组_____元素的_____。
3. 判断题
(1)值传递函数内部对参数的修改一定不会影响实参。( )
(2)const int &x 可以绑定字面量,如 func(5)。( )
4. 读代码写结果
void mystery(int &a, int b) {
a = a + b;
b = a - b;
}
int main() {
int x = 5, y = 3;
mystery(x, y);
cout << x << " " << y;
}输出:_____ _____
提高题
5. 编程题 — 多个返回值
函数只能 return 一个值。但如果你需要"返回"两个值怎么办?用引用传递!写一个函数 void minmax(int a, int b, int &mn, int &mx),把较小值存入 mn,较大值存入 mx。在 main() 里测试。
输入示例:a=15, b=8
输出示例:min=8, max=15
6. 编程题 — 数组统计
写一个函数 void stats(const int arr[], int n, int &sum, double &avg),计算数组的总和与平均值。注意:用 const 保护数组,用引用"返回"两个计算结果。
测试数据:{85, 92, 78, 90, 88}
预期输出:sum=433, avg=86.6
7. 编程题 — 字符串大写转换
写一个函数 void toUpper(string &s),用引用传递把字符串中所有的小写字母转换为大写字母。在 main() 里输入一个字符串,输出转换后的结果。
(提示:遍历字符串每个字符,用 toupper(c) 函数,需要 #include <cctype>)
挑战题
8. 编程题 — 数组去重(原地修改)
写一个函数 int unique(int arr[], int n),把有序数组中重复的元素去掉(原地修改),返回去重后的新长度。
示例:
输入:arr = {1, 1, 2, 3, 3, 3, 4, 5, 5}, n = 9
输出:新长度 = 5, arr 前 5 位变为 {1, 2, 3, 4, 5}要求:不用额外数组,直接原地修改,时间复杂度 O(n)。
(提示:用快慢指针——一个指针遍历,一个指针记录"不重复元素应该放的位置")
9. 编程题 — 数组区间反转
写一个函数 void reverseRange(int arr[], int L, int R),用引用思维(数组本身就传地址)将数组下标 [L, R] 区间内的元素反转。在 main() 里输入数组和区间,输出反转后的完整数组。
示例:arr = {1, 2, 3, 4, 5, 6, 7}, L=2, R=5 → {1, 2, 6, 5, 4, 3, 7}
🧠 本章小结
┌──────────────┬──────────────────┬─────────────────────┐
│ 值传递 │ 引用传递 (&) │ const 引用 (&) │
┌──────────┼──────────────┼──────────────────┼─────────────────────┤
│ 本质 │ 复制一份副本 │ 原变量的别名 │ 原变量的只读别名 │
│ 内存 │ 新开空间 │ 共用原地址 │ 共用原地址 │
│ 修改影响 │ ❌ 不影响原值 │ ✅ 直接修改原值 │ ❌ 编译器禁止修改 │
│ 传字面量 │ ✅ 可以 │ ❌ 不行 │ ✅ 可以 │
│ 适用场景 │ 小数据类型 │ 需要修改原变量 │ 大对象只读 │
└──────────┴──────────────┴──────────────────┴─────────────────────┘
数组参数:自动退化为指针,传递的是首地址 → 函数内修改直接影响原数组
swap 的正确写法:void swap(int &a, int &b) ← 必须用引用!📝 配套练习
共5题。值vs引用的核心差别需要通过"改不掉"和"改得掉"的对比来理解。Ch24题量精简。。★核心(课堂必做) ◆拓展(课后练习)
| 级别 | 题号 | 链接 | 覆盖知识点 |
|---|---|---|---|
| ★ 核心 | luogu-P5735 | https://hydro.ac/p/luogu-P5735 | 值传递、只读不写 |
| ◆ 拓展 | J0101 | https://hydro.ac/d/srqc/p/J0101 | 值传递+bool返回、设计决策 |
| ◆ 拓展 | J0102 | https://hydro.ac/d/srqc/p/J0102 | 值传递+bool返回、区间 |
| ◆ 拓展 | luogu-P5743 | https://hydro.ac/p/luogu-P5743 | 值传递、递归计算 |
| ◆ 拓展 | luogu-P5744 | https://hydro.ac/p/luogu-P5744 | 结构体+函数、传结构体 |
💡 练习建议:先完成 1 道★核心题,确保掌握本章基本方法;再完成 4 道◆拓展题,覆盖不同变式和细节。
配套练习
共5题。值vs引用的核心差别需要通过"改不掉"和"改得掉"的对比来理解。Ch24题量精简。★核心(课堂必做) · ◆拓展(课后练习)
| 级别 | 题号 | 链接 | 覆盖知识点 |
|---|---|---|---|
| ★ 核心 | luogu-P5735 | https://hydro.ac/p/luogu-P5735 | 值传递、只读不写 |
| ◆ 拓展 | J0101 | https://hydro.ac/d/srqc/p/J0101 | 值传递+bool返回、设计决策 |
| ◆ 拓展 | J0102 | https://hydro.ac/d/srqc/p/J0102 | 值传递+bool返回、区间 |
| ◆ 拓展 | luogu-P5743 | https://hydro.ac/p/luogu-P5743 | 值传递、递归计算 |
| ◆ 拓展 | luogu-P5744 | https://hydro.ac/p/luogu-P5744 | 结构体+函数、传结构体 |
练习建议:先在课堂完成 1 道★核心题,掌握本章基本方法;课后完成 4 道◆拓展题,覆盖不同变式和细节。
自查清单:
- [ ] 我能在脑子里画出值传递和引用传递的内存模型(副本 vs 别名)
- [ ] 写 swap 函数时我会本能地加上
& - [ ] 我知道
const &的三重好处:不复制、只读、能接受字面量 - [ ] 我能判断一个场景该用哪种传递方式
- [ ] 我理解数组作为参数时"不复制、传地址"的特性
- [ ] 调用引用传递函数时,我不会傻傻地在实参前加
&
🚀 下章预告:参数搞懂了——但变量自己呢?为什么
if里声明的变量出了大括号就消失?全局变量方便为什么老师说它是"万恶之源"?第 25 章,我们来画一张"变量地图"——作用域与生命周期。