C++ 引用入门:参数传递、只读借用与返回风险
07 引用:给同一个对象再起一个名字
在之前讨论变量名的时候,我们曾把名字看作是访问某个对象的入口。而引用的概念则把这件事又往前推了一步:它允许我们为代码中已经存在的对象,再额外起一个新的名字。
我们可以先看一个最简单的例子:
int score = 90;
int& ref = score;
在这段代码中,score 是一个普通的 int 对象的名字。而下一行的 ref 则是绑定到这个对象上的引用。完成这步绑定之后,无论是通过 score 还是通过 ref 去修改数据,最终改动的都会是同一个底层对象。
ref = 95;
std::cout << score << "\n";
这段代码的输出结果会是 95。因为 ref 和 score 指向的完全是同一个整数对象,唯一的区别只是我们在代码中使用了不同的名字来访问它。
初学者对引用最容易产生误解的地方就在于此:引用并不是用来保存数据副本的变量。像 int& ref = score; 这行代码,在后台并没有真正去创建第二个整数对象,它仅仅是让 ref 这个名字绑定到了 score 所代表的那个对象上。在后续的代码中,读取 ref 就等同于读取那个对象当前的值,而修改 ref 也是在直接修改那个对象。一旦把这层关系彻底理清,后续再学习诸如引用参数、const 引用以及返回引用等进阶概念时,思路就会顺畅很多。
引用必须绑定到对象
在使用引用时,一个最基础的规则是它在定义时必须进行初始化:
int score = 90;
int& ref = score;
这里的 int& 代表这是一个绑定到 int 类型对象上的引用。在定义 ref 的同时,必须立刻让它绑定到某个实际存在的 int 对象上。一旦这种绑定关系建立完成,在日常的代码编写中,我们完全可以把它当作原始对象的别名来看待。
“必须立刻绑定”是理解引用的一个关键点。普通的变量可以先定义、稍后再赋值;但引用在被定义出来的那一瞬间,就必须明确声明它是哪个对象的别名。正是这条规则,使得引用在日常使用中表现得像是一个非常稳定的名字,它不像指针那样可以在运行期间随意更改指向的目标。因此,当你在代码中看到一个引用变量时,完全可以按照“它从声明起就固定绑定到了某个具体对象”的逻辑去进行推理。
下面这段代码更清晰地展示了引用与原对象之间的这种连带关系:
#include <iostream>
int main() {
int score = 90;
int& ref = score;
std::cout << score << "\n";
std::cout << ref << "\n";
ref = 95;
std::cout << score << "\n";
return 0;
}
这种关系可以用简单的图示来表示:
同一个 int 对象,当前值 95
↑
score
↑
ref
从图中可以看出,score 和 ref 都是通向同一个对象的数据入口。不论你通过哪一个名字进行写入操作,最终改变的都是内存里的同一份数据。
在基础学习阶段,我们主要探讨的是这类的常规引用(严格来说叫左值引用),也就是绑定到那些有着稳定内存位置的对象上的引用。至于像右值引用、移动语义以及完美转发等更深入的话题,我们会放到后续的进阶内容中再作展开。
引用之所以比生活中普通的“别名”概念要求更严,是因为它不仅依赖目标对象已经存在,还高度依赖该对象的生命周期。只有当 score 这个对象还存在时,ref 这个引用才有实际意义;一旦 score 的生命周期结束,继续尝试使用 ref 就会进入非常危险的区域。正如我们在第 06 章中提到的,局部变量会随着作用域的结束而销毁,这条生命周期的规则在引入引用的概念后,直接变成了一道必须时刻留意的安全边界。
引用在完成绑定之后,在语法层面的使用体验和普通变量名几乎没有区别。这正是引用用起来顺手的地方,同时也是需要格外小心的地方。虽然 ref = 95; 这行代码表面上看起来像是在修改 ref 这个变量本身,但实际上修改的是底层的 score 对象。因此,在阅读包含引用的代码时,不能只盯着当前的名字看,还需要明确它到底绑定在了哪一个实体对象上。这种绑定的关系通常会在它被定义的地方,或者是函数的参数列表中给出。
值传递会复制,引用传递绑定原对象
在日常开发中,引用的身影最常出现在函数的参数列表中。为了形成对比,我们可以先回顾一下普通的按值传递:
void AddOneByValue(int x) {
++x;
}
当我们调用这个函数时:
int score = 90;
AddOneByValue(score);
std::cout << score << "\n";
执行完毕后,score 的值仍然是 90。这是因为在调用发生时,score 只是把自己的值复制给了形参 x,函数内部实际修改的仅仅是 x 这个独立的副本。
这其实就是按值传递最基本的心智模型:调用者将数据拷贝一份交给函数,函数拿到的是一份属于自己的局部对象。无论函数在内部怎么折腾这个局部副本,这些修改都不会影响到调用者那边的原始数据。对于像 int 这样体积很小的基础类型,这种做法不仅简单安全,而且逻辑非常清晰。当函数只需要读取一个小范围的值而不需要修改它时,按值传递无疑是最自然的写法。
接下来,我们再来看看如果把参数换成引用会发生什么:
void AddOneByReference(int& x) {
++x;
}
同样去调用它:
int score = 90;
AddOneByReference(score);
std::cout << score << "\n";
这一次,score 的值就会变成 91。因为这里的形参 x 已经不再是一个独立的副本了,而是一个直接绑定到调用者对象上的引用。函数内部执行 ++x 时,修改的正是外部传进来的那个原始对象。
所以,引用传递的心智模型就完全不同了:调用者实际上是在允许函数通过另一个名字来直接访问自己的对象。在这个场景下,函数内部的 x 和外部调用点的 score 变成了同一个对象的两个不同称呼。虽然 ++x 的代码是写在函数内部的,但它所产生的修改效果却是直接作用在调用者那边的。
我们可以通过一段完整的代码来对比两者的差异:
#include <iostream>
void AddOneByValue(int x) {
++x;
}
void AddOneByReference(int& x) {
++x;
}
int main() {
int a = 10;
AddOneByValue(a);
std::cout << "after value = " << a << "\n";
int b = 10;
AddOneByReference(b);
std::cout << "after reference = " << b << "\n";
return 0;
}
整个过程也可以用一张图来梳理:
这种机制上的差别是非常关键的。在声明函数时,如果参数类型写成 int,通常意味着函数只需要拿一份数值副本去进行计算;但如果写成了带引用的 int&,就表明这个函数打算直接接触并操作调用者的数据对象。
在阅读函数的接口声明时,我们要习惯性地读出这层隐含的意思。像 void AddOneByValue(int x) 表达的语义是“请传给我一个整数值”;而 void AddOneByReference(int& x) 传达的语义则是“我要直接访问你的整数对象,并且我极有可能在内部修改它”。参数类型的变化,直接重新定义了函数与调用者之间的交互边界。
值得注意的是,不仅声明要清晰,调用点的函数名也需要配合表达出修改的意图。像 AddOneByReference(score) 这样的调用最终会修改 score,但因为 C++ 语法在调用处并没有强制要求加上诸如 & 或 * 之类的特殊符号,阅读代码的人往往只能依赖函数的命名和签名来判断外部对象是否面临被修改的风险。正因为这样,带有修改性质的引用参数非常不适合隐藏在那些表意模糊的函数名背后。函数的名字起得越准确,引用参数的使用就会越安全。
引用参数适合表达修改意图
引用参数最典型的应用场景,就是让函数能够直接去修改调用方传入的对象。这其中最经典的例子莫过于交换两个整数:
void Swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
当我们在代码中这样调用时:
int x = 3;
int y = 4;
Swap(x, y);
一旦函数执行完毕,x 的值就会变成 4,而 y 的值变成 3。在这个过程中,Swap 内部的 a 绑定到了 x 上,b 绑定到了 y 上。函数在内部对 a 和 b 所做的任何赋值操作,实际上都是在直接操作外部的 x 和 y。
把这段逻辑放进完整的程序中看会更加清晰:
#include <iostream>
void Swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
int main() {
int x = 3;
int y = 4;
Swap(x, y);
std::cout << "x = " << x << ", y = " << y << "\n";
return 0;
}
由于引用参数的接口语义非常强烈,当我们在代码中看到 void Swap(int& a, int& b) 这样的签名时,第一时间就应当反应过来:这个函数是具备修改传入对象能力的。反之,我们在自己编写带有引用参数的函数时,也应该通过命名让这种修改意图变得显而易见。
在基础的开发阶段,对于确实需要被修改的参数,我们通常会直接使用这种普通的引用(非 const 引用)。而如果仅仅是为了避免拷贝而想要只读地访问一个大型对象,就会用到下一章将详细介绍的 const 引用(const T&)。
在实际的工程实践中,关于非 const 引用参数有一个普遍的习惯,那就是需要保持审慎。它虽然非常便利,但由于调用处没有显式的符号提示,往往会隐藏对象可能会被修改的细节。像 Swap(x, y) 这样的命名还能让人一眼看出意图,但如果遇到像 Normalize(name) 且接收类型为 std::string& 的函数,调用者就必须通过查阅签名或文档才能确认自己的 name 数据是否会被改变。因此,这类修改型的引用参数一定要搭配能够准确描述行为的函数名,让接口能够自我解释。
总结来说,引用参数最适合应用在三种场景下:一是明确需要修改调用者的对象;二是单纯为了避免大对象复制且只打算只读访问;三是需要在函数中一次性返回多个结果。对于初学者,建议对第三种场景保持克制,因为过多的输出参数容易导致函数的接口变得臃肿且难以理解。如果可以通过正常的返回值来表达结果,就优先使用返回值;只有当函数的逻辑确实需要就地修改已存在的对象时,再考虑让引用参数来承担这一职责。
引用也能减少复制
如果是传递像 int 这样基础且小体积的数据类型,复制的开销小到几乎可以忽略不计。但如果涉及到像 std::string 或是 std::vector 这种体量较大的对象,每一次按值传递都会带来可观的内存分配和数据拷贝成本。在这种情况下,引用由于能让函数直接访问外部的源对象,自然也就避免了这些不必要的复制。
不过需要明确的是,避免复制仅仅是我们优化代码的目标之一,确保函数接口传达出正确的逻辑意图往往更加重要。一般而言,如果函数在逻辑上必须修改对象,我们就采用普通的引用 T&;如果函数只是需要读取一个可能体积较大的对象而无需修改它,我们倾向于使用常引用 const T&;如果函数仅仅是需要一个小体积的数值进行计算,直接使用按值传递 T 往往是更清晰的做法。有些初学者在发现引用能节省开销后,习惯性地把所有参数都改成引用,这反而会让所有的参数交互都变成间接访问,无形中增加了代码阅读时的理解成本。
我们可以看一个负责只读打印的函数示例:
#include <iostream>
#include <string>
void PrintName(const std::string& name) {
std::cout << name << "\n";
}
在这个例子中,参数使用了 const std::string&,也就是所谓的只读引用。程序在调用这个函数时既不会产生完整的字符串拷贝,同时也从语法层面限制了在函数内部对 name 数据进行任何修改。这种写法在实际的工程代码中是非常标准且常见的。
在现阶段的学习中,我们可以先记住这两类最常用的引用参数:
T& → 函数有可能会修改传入的对象
const T& → 函数仅进行只读访问,主要目的是为了避免复制大对象
关于 const 更系统、更全面的规则我们在下一章会作详细讲解。目前只需建立一个基础认知:合理运用引用参数,既可以在必要时提供修改外部对象的能力,又能在面对大对象时省去昂贵的复制开销。
这两类引用加上传统的按值传递,构成了现代 C++ 中最常见的三种函数参数形态:
int score → 函数只需拿到一份简单的数值拷贝
std::string& name → 函数大概率会修改调用者传进来的原始字符串
const std::string& name → 函数只是为了只读访问调用者的字符串,并规避了拷贝成本
在阅读较为复杂的代码时,一个非常有效的方法是:先通过参数类型判断出函数与外部调用者之间建立了怎样的访问关系,理清这层关系之后,再深入去阅读函数内部的具体细节。遵循这样一个由外向内的理解顺序,往往比一上来就逐行去追代码逻辑要稳健得多。
返回引用要非常谨慎
除了用作参数,函数其实也是可以直接返回引用的。但这涉及到了严格的生命周期边界,如果不加注意就会写出极其危险的代码,其中最典型也是最致命的错误就是返回一个局部变量的引用:
int& Bad() {
int local = 42;
return local;
}
在这段代码中,local 是 Bad 函数内部临时创建的一个局部变量。当函数执行完毕准备返回时,local 的生命周期就自然结束了。如果此时我们执意把它的引用当做返回值交出去,调用方拿到手的就会是一个指向已经被销毁内存的失效入口。如果程序继续顺着这个失效的引用去读写数据,其行为将是完全不可预知的,这种现象在底层开发中是非常危险的。
我们可以用简单的流程来描述这条危险的路径:
当然,返回引用在 C++ 中是有其正当和合法用法的,比如去返回某个长期存在于全局范围内的对象,或者是去返回某个集合容器内部的元素引用。但所有这些合法场景的前提,都是要求开发者对对象的生命周期有绝对清晰的掌控。在基础阶段,我们只需要牢记一条不可逾越的红线即可:绝对不要去返回任何局部变量的引用。
合法返回引用的场景中,最核心的一点就是:被返回的那个对象在函数调用结束后,必须确信它还好好地活着。比如我们在访问数组或容器的某个下标时,系统返回的就是其中元素的引用,这是因为作为主体的容器一直存在,其内部的元素自然也就安全。危险返回的问题永远出在底层对象已经不复存在,而引用的外壳却依然游荡在代码中。必须铭记一个基本事实:引用这个概念本身,是没有任何能力去延长一个普通局部对象生命周期的。
因此,当你考虑是否要让函数返回一个引用时,核心的考量从来都不是语法通不通,而是背后的生命周期管理。你必须能够清楚地回答这几个问题:返回出去的这个对象究竟属于谁?它预计能存活到什么时候?当外部的调用者真正通过引用去访问它时,它还能否确保依然健在?如果对这些问题没有十足的把握,那最稳妥的选择就是放弃返回引用。在夯实基础的阶段,严守这条安全规则,远比提前去尝试那些花哨的高阶技巧要踏实得多。
引用和指针的关系先放一个预告
在讨论间接访问对象时,引用和指针是两个绕不开的概念。虽然它们都能达到接触底层对象的目的,但无论在语法层面还是在语义层面,两者的使用习惯都有着明显的区别。引用在绑定完成后,日常的读写操作几乎和使用原对象一样自然:
int score = 90;
int& ref = score;
ref = 95;
而指针在底层保存的则是对象所在的具体内存地址,使用时需要显式地进行解引用操作:
int score = 90;
int* p = &score;
*p = 95;
除了语法的不同,指针本身具备能够表示“空状态(空指针)”的能力,并且可以在程序运行期间随意更改它指向的目标对象。相比之下,引用的语义更加坚定,它更适合用来表达一种强烈的契约:“这里一定存在着一个合法的对象,我现在只是给它起了一个备用名称而已”。关于指针的详细机制,我们在第 09 章会有系统的梳理。
虽然引用和指针都能赋予函数接触外部对象的能力,但它们在函数接口上所传达的态度是截然不同的。引用通常在暗示:“如果你要调用我,就必须老老实实给我传一个真实存在的对象过来”;而指针的暗示则是相对宽松的:“你传进来的可能是一个有效的对象,也有可能只是一个表示没有数据的空指针”。正因为如此,类似于 Print(const std::string& name) 这样的设计,在表达上要比 Print(const std::string* name) 直接得多,因为对于执行“打印”这个动作来说,名字数据本身的存在往往是硬性前提。但在某些像查找一类的功能中,让函数返回一个指针会更合适,因为你可以通过返回一个空指针来干净利落地传达“没有找到目标”的业务逻辑。
在目前的阶段,我们可以用下面这个简单的逻辑来进行判断选型:
🏂
需要直接修改调用者的原始对象 → 使用 T&
仅需只读访问,且希望能避免大对象拷贝 → 使用 const T&
逻辑上需要表达对象可能压根不存在的情况 → 使用指针(后续章节会详细讲解)
一段完整演示
最后,我们通过一段综合性的代码,把普通的引用绑定、引用作为参数传递、修改型的交换逻辑以及只读引用一并回顾一下:
#include <iostream>
#include <string>
void AddOne(int& value) {
++value;
}
void Swap(int& a, int& b) {
int temp = a;
a = b;
b = temp;
}
void PrintName(const std::string& name) {
std::cout << name << "\n";
}
int main() {
int score = 90;
int& score_ref = score;
score_ref = 95;
std::cout << "score = " << score << "\n";
AddOne(score);
std::cout << "score = " << score << "\n";
int x = 3;
int y = 4;
Swap(x, y);
std::cout << "x = " << x << ", y = " << y << "\n";
std::string name = "Ada";
PrintName(name);
return 0;
}
在阅读这类包含了多种引用形式的代码时,只要看到 & 紧跟在类型名称后面,就可以按“引用”的逻辑去解读它。比如 int& value 就是一个专门绑定到整型对象的引用参数,而 const std::string& name 则是被施加了只读限制的字符串引用。
可以说,引用的概念成功地把“一个对象允许存在多个不同名字”这件事,严谨地整合进了 C++ 的类型系统当中。而在紧接着的下一章里,我们将会顺着这个思路继续向前迈进:尝试在引用和普通的变量前面加上 const 关键字,进而把“这条数据访问路径到底允不允许发生修改”的意图,也一并写进稳固的类型系统里去。
回顾整个章节,我最希望你能形成的阅读反射是:在代码中看到 & 出现在类型里时,能够迅速识别出这是一个引用。一旦确认了引用的身份,就自然地顺着对象的生命周期和函数的接口意图去深入思考——它当前究竟绑定在哪个具体的对象上?这个底层对象能够存活到哪一步?函数要求拿到这个引用的目的是为了就地修改、是为了只读保护,还是仅仅为了免去一次昂贵的拷贝?只要在脑海里常备这三个问题,你就能从容地看懂大多数基础阶段涉及到引用的代码逻辑。
阅读导航




