目录
摘要:
一:概念
二:引用的书写
三:权限问题
四:产生临时变量的场景
1:类型转换
2:函数采用值返回
五:引用的使用场景
1:做函数参数
2:做函数返回值
3:错误使用static的情况
4:函数值返回触发临时变量机制
六:指针 VS 引用
摘要:
本文讲解C++中的引用,从引用的书写格式,到引用涉及到的权限问题,再到如何正确使用引用,最后对比指针和引用.....
一:概念
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空
间,它和它引用的变量共用同一块内存空间。
比如:李逵,在家称为"铁牛",江湖上人称"黑旋风",但都是指的同一个人!
需要注意的是,引用没有开辟任何的内存空间,这是语法上的描述,而在底层,引用其实和指针一致,都是开辟了空间的,在使用的时候,引用就像一个语法糖包装过的、有着诸多限制的指针。但引用在C++仍有自己不可被指针替代的场景
而至于为什么语法和底层相悖,可以理解为:
语法说“没有空间”,这是一种按约定办事的设计。它给你一个绝对安全、纯粹、无歧义的“别名”抽象,你只能也只需按这个约定来思考代码,对于我们理解来说是轻松的。而底层开辟空间,则是编译器为了在物理机器上实现这个约定而做的“幕后工作”。这个表面矛盾,恰恰是C++在追求“零开销抽象”时的一种精巧平衡。也就是说,为了更安全、更清晰地表达“传递别名”的意图,故意在语义上去掉了这些特性。
例子:验证引用和引用的变量共用同一块内存空间
#include<iostream> using namespace std; int main() { int a = 10; int& ra = a;//<====定义引用类型 printf("%p\n", &a); printf("%p\n", &ra); return 0; }二:引用的书写
引用书写时,有三条准则:
a.引用在定义时必须初始化
b.一个变量可以有多个引用
c.引用一旦引用一个实体,再不能引用其他实体
a:任何的引用一定是定义时就初始化的,就像黑旋风这个别名,被别人创造出来的时候,就是李逵的别名,二者从一开始就绑定,不可能凭空出现黑旋风这个外号,但没有匹配的人;同理,引用定义的时候就要被变量初始化,否则后序给该引用赋值两个不同的值,编译器不知道该选哪个
b:李逵不止黑旋风,还有铁牛这个别名,所以一个变量,也可以有两个不同名的引用
c:黑旋风一开始就是李逵的别名,不会变成其他人的别名,任何变量的引用,都必须从始至终引用该变量
代码举例:
a. 引用在定义时必须初始化
int main() { int a = 10; int& ref = a; // 正确:诞生即绑定 int& ref2; // 错误!引用必须初始化,就像不能先起个空的外号再找人 return 0; }b. 一个变量可以有多个引用
int main() { int a = 10; int& ref1 = a; // 别名1:黑旋风 int& ref2 = a; // 别名2:铁牛 int& ref3 = a; // 别名3:李铁牛 // 对任一别名操作,都等同于操作 a 本身 ref1 = 20; cout << ref2; // 输出20,铁牛也看到了变化 return 0; }解释:同一个变量的多个引用都是共用该变量的空间,所以对其中一个引用ref1赋值,其余引用和变量本身都会被修改
c. 引用一旦引用一个实体,再不能引用其他实体
int main() { int a = 10; int b = 20; int& ref = a; // 黑旋风 = 李逵 ref = b; // 这一行不是让 ref 变成 b 的引用! // 而是把 b 的值赋给 ref 引用的实体 (也就是a) // 结果:a = 20,b = 20 cout << "ref的值从" << a << "变成了" << b << endl; // 验证:ref 仍然紧绑 a cout << "但ret的地址从未改变 " << &ref << " vs " << &a; // 地址完全相同,还是一家人 return 0; }解释:ref是a的引用,再进行ref=b;这行代码是不可能将ref改成b的引用的,只能是把b的值赋给了ref,让a这个变量和他的引用的值都被修改了而已
介绍完引用的书写规则之后,我们就可以解释某些数据结构教材上的代码,其编写者
三:权限问题
💡权限的规则:可平移,可缩小,不可放大
被const 修饰的变量,权限低于普通变量,因为前者只可读,后者可读可写,所以引用一个普通变量,则你定义引用类型时,可以被const修饰(缩小),也可以没有const修饰(平移),反之,引用一个const修饰的变量,你定义引用类型时,只能是没有const修饰的引用(平移)
例子1:权限平移
int main() { // 普通变量 → 普通引用(权限不变) int a = 10; int& ref = a; // 可读可写 → 可读可写,权限一致 // const 变量 → const 引用(权限不变) const int b = 20; const int& ref2 = b; // 只读 → 只读,权限一致 return 0; }例子2:权限缩小
int main() { int a = 10; // 权限缩小:普通变量 → const 引用 const int& ref = a; // 我有读写权,但我自愿只读着用,安全 a = 20; // 可以通过原变量修改 // ref = 30; // 错误!通过这个引用,我只能读,不能写 return 0; }例子3:权限放大(❌️)
int main() { const int b = 20; const int& cref = b;// ✅️ // 权限放大:const 变量 → 普通引用,编译器直接报错! int& ref = b; // ❌ 错误:b 是只读的,不能给一个可读可写的引用 // 同样,const 引用 转 普通引用也不行 int &ref2 = cref; // ❌ 错误:cref 只有只读权限,不能放大传递 return 0; }解释:不仅不可以用int类型的引用去引用一个const int。此外也不能把一个const int 类型的引用变成int,同样是权限放大
但还有些如下情况,比较难以分清!!
1:const 类型的值赋值给普通变量,不属于权限放大
int main() { const int a = 10; // a 只读 int rra = a; // rra 是一个全新的的变量 其只是接收了a的赋值 得到了a的值 仅此而已 return 0; }解释:a是一个const int类型的值,将a赋给了rra,不存在权限放大,rra是全新的整形变量,其的值接收了a的赋值了而已
2:常性的临时变量,赋给const引用,属于权限平移
int main() { int a = 10; // 假设 a 是一个普通变量 // 1. ✅ 允许:const引用绑定到字面量常量 const int& ra = 30; // 解析:30 是字面量,编译器会创建一个常性的临时变量来存它。 // ra 是这个常性临时变量的引用,权限平移(常性 ↔ 常性)。 // 2. ✅ 允许:const引用绑定到表达式结果 const int& rb = a * 3; // 解析:a*3 的结果是一个常性的临时变量。 // rb 是这个常性临时变量的引用,权限平移。 // 3. ❌ 禁止:普通引用绑定到表达式结果 // int& rc = a * 3; // 错误!a*3 的结果是常性临时变量,试图用普通(可读写)引用去引用它,权限放大! // 4. ❌ 禁止:普通引用绑定到字面量 // int& rd = 30; // 错误!30 对应的常性临时变量,同样不能绑定到普通引用,权限放大! }解释:
①:a*3和30本质上一样:它们都不是已经存在于内存中的、有名字的变量。编译器必须创建一个临时变量来存放结果,而这个临时变量在类型上天然带有 常性(被视作const修饰的只读空间)
②:权限不可放大:试图用一个普通引用(可读可写权限)去操作一个常性临时变量(只读权限),就是权限方法,编译器必定报错。
③:反之,const int&的引用,权限是只读的。用只读的别名去关联只读的临时空间,是完全匹配的权限平移,因此被允许。这条规则也直接导致了常量引用(const T&)能够延长临时变量生命周期这一重要特性。
注:其中的“常量引用(
const T&)能够延长临时变量生命周期”,指的是普通的 30 这个临时变量,在所处行代码一结束生命就结束了,而你给它起了个别名ref,那编译器认为你确实想长期用它。于是规定:这个临时对象不能死,要活到ref这个引用结束为止。
四:产生临时变量的场景
1:类型转换
类型转换时会产生临时变量
int main() { double d = 12.34; // ❌ 错误:权限放大 int& rd = d; return 0; }解释:
①:警告不是因为类型不匹配,因为即使类型不同,但double和int都是可读可写,应当权限平移不会报错
②:报错是因为从double到int,这是类型转换,所以编译器用一个int类型的临时变量来存储d的整形部分,然后再把临时变量赋给引用rd,重点在于临时变量具有常性,也就是只可读不可写,把临时变量赋给一个int类型的引用,则权限放大!
// 1. 因为类型不匹配(double vs int),编译器必须创建一个临时变量 const int temp = (int)d; // temp = 12,且 temp 具有常性 // 2. 然后尝试将 rd 绑定到这个临时变量 int& rd = temp; // ❌ 这里出错了!③:总结,临时变量temp是常性的(只读),引用rd是普通int&(可读写),试图用可读写的引用去操作只读的临时变量 = 权限放大
✅️:所以,解决办法就是用const修饰rd,从而和临时变量形成权限平移
int main() { double d = 12.34; // ✅ 正确:权限平移 const int& rd = d; return 0; }2:函数采用值返回
函数采用值返回时会产生临时变量
#include <iostream> int add(int x, int y) { int sum = x + y; // sum 是局部变量 return sum; // ① 这里会把 sum 的值拷贝到一个临时变量中 } // ② sum 随函数结束而销毁 int main() { int result = add(3, 4); // ③ 临时变量再拷贝给 result // 或者直接用: std::cout << add(5, 6) << std::endl; // 临时变量直接参与表达式 return 0; }解释:
①:函数采用值返回时会产生临时变量,当函数返回的值是栈上开辟的,则会随着栈帧的销毁而销毁,所以编译器为了确保能把函数返回值给返回出来,会把sum的值拷贝到一个具有常性的临时变量中,让用户得到这个临时变量,用户常常创建变量来接受这个临时变量
②:注意,临时变量的值赋给int类型的变量,不是权限放大,在上文第三大点第1小点中解释过,这只是一次简单的赋值
所以如果我们把 int result = add(3, 4); 变成int& result = add(3, 4);则必定报错,一个int类型的引用想要去引用一个临时变量,权限放大!这也从侧面验证了函数采用值返回时会产生临时变量!所以导致二者权限不等
#include <iostream> int add(int x, int y) { int sum = x + y; // sum 是局部变量 return sum; // ① 这里会把 sum 的值拷贝到一个临时变量中 } // ② sum 随函数结束而销毁 int main() { int& result = add(3, 4); // ③ 临时变量再拷贝给 result // 或者直接用: std::cout << add(5, 6) << std::endl; // 临时变量直接参与表达式 return 0; }此外,我们让add(3, 4);直接++,add(3, 4)++,也可以验证函数采用值返回时会产生临时变量
#include <iostream> int add(int x, int y) { int sum = x + y; // sum 是局部变量 return sum; // ① 这里会把 sum 的值拷贝到一个临时变量中 } // ② sum 随函数结束而销毁 int main() { add(3, 4)++; std::cout << add(5, 6) << std::endl; return 0; }解释:add(3, 4)返回的是临时变量,临时变量具有常性当然不可以++,所以必然报错!
💡注意:
①:这个函数采用值返回时会产生临时变量的设计是合理的!因为函数中的变量会随函数调用完成后,随着栈帧一起销毁;所以为了精确返回函数返回值,编译器采取了此种措施,并且可以拓展的一点是,只要函数采用值返回时,不管这个返回的值是否会随栈帧销毁而销毁,编译器都会为了确保正确放回,从而产生临时变量
②:也就是说,如果返回的值是static修饰的,生命周期虽程序,但若函数采用值返回时,仍会产生临时变量
③:所以函数返回时,出了函数作用域,如果返回对象还已经还给系统了,则必须使用传值返回,因为编译器会采取临时变量的措施,让其正确返回
五:引用的使用场景
引用在日常使用中,最常用的场景是
1:做函数参数
2:做函数的返回值
1:做函数参数
#include <iostream> #include <string> using namespace std; // ❌ 值传递:会发生拷贝,效率低 void printByValue(string s) { std::cout << s << std::endl; } // ✅ 引用传递:不拷贝,直接操作原对象 void printByRef(const string& s) { std::cout << s << std::endl; } int main() { string longStr = "这是一个很长的字符串..."; printByRef(longStr); // 高效,无拷贝 return 0; }解释:
①:引用做参数是因为可以省去不必要的拷贝,当实参是一个巨长的链表,此时形参采用值接收,则会拷贝生成一份巨长的链表,这无疑是浪费空间的
②:其次即使你采用指针接收链表,但指针也是占据空间的,而引用在语法上是不需要开辟任何空间的,所以其在这些地方时优于指针的!
③:引用做函数参数,在C++中99%场景下会和const结合使用, 代表不会对接收到的参数本身进行修改,既高效又安全
2:做函数返回值
引用作函数返回值,其原因也是想省去不必要的拷贝,因为函数采用值返回,则一定会触发临时变量的拷贝,所以引用作返回值就是想省去这一步,但是需要注意了!!会引入新的问题!
比如返回变量会随栈帧销毁而销毁,此时你还引用返回,其实就是造成了类似野指针的问题!!
错误使用引用作函数返回值的例子:
#include <iostream> using namespace std; int& add(int x, int y) { int sum = x + y;// sum 是局部变量 return sum; } int main() { int& result = add(3, 4); cout << result << endl; return 0; }解释:
①:打印结果是对的,我们函数采用引用返回,也避免了产生临时变量所带来的拷贝消耗,看似是对的??但实则已经犯了大错!!
②:因为sum是栈上的局部变量,其会随栈帧销毁而销毁,你result引用的实体早已销毁!这类似于野指针错误!而打印出7,完全是因为那部分空间存储的7未被清除,什么时候清理取决于编译器,不同编译器结果可能不同
比如我们在之后再调用一次add函数,对5和5相加,此时result会从7变成10
#include <iostream> using namespace std; int& add(int x, int y) { int sum = x + y;// sum 是局部变量 return sum; } int main() { int& result = add(3, 4); add(5, 5); cout << result << endl; }解释:
①:之前打印7是因为编译器还没去清除栈帧内的变量,而再次调用add,产生新的栈帧就会覆盖原有的空间,导致result的空间内容被修改,所以这就是错误使用引用作为函数返回值的后果!
②:当然这里其实也证明了,栈的空间会被不同的函数栈帧重复使用,两个相同的add函数前后调用,大概率使用的是同一块栈的空间作为栈帧!导致两次存放sum的空间是一致的
💡:我们也可以通过操作,让这个代码打印不出7,从而证明随着栈帧销毁,sum也不复存在
#include <iostream> using namespace std; int& add(int x, int y) { int sum = x + y; // sum 是局部变量,存储在栈上 return sum; // ❌ 危险:返回局部变量的引用 } // 新增一个函数,用来“污染”之前 add 函数占用的栈空间 void overwriteStack() { int arr[10] = { 9, 9, 9, 9, 9, 9, 9, 9, 9, 9 }; // 随意填充数据 for (int i = 0; i < 10; i++) { cout << arr[i] << " "; // 防止编译器优化掉 arr } cout << endl; } int main() { int& result = add(3, 4); // result 指向一块已释放的栈内存(值应该是7) overwriteStack(); // 这块栈内存现在被新的函数调用覆盖了 cout << result << endl; // 输出垃圾值,大概率不再是 7 return 0; }解释:result变成了9,而9是一个不相干的数组的元素值,所以也证明result引用的空间早已销毁,被新的内容所填充
💡:所以为了避免错误的使用引用作为函数的返回值,我们需要确保让返回值不会随着栈帧销毁而销毁,如果该返回的变量本身就是堆上的,那我们无需操作,而如果是栈上的,像上文那样,则我们需要使用static修饰该变量,让栈上变量变成静态存储区上的变量(生命周期延长)
✅️正确使用引用作为返回值的代码:
#include <iostream> using namespace std; int& add(int x, int y) { static int sum; // ✅ 静态变量,生命周期随程序 sum = x + y; // 每次调用都覆盖之前的值 return sum; // ✅ 安全:返回的是活着的变量 } int main() { int& result = add(3, 4); // result 绑定到 sum,sum = 7 add(5, 5); // sum 被覆盖为 10 cout << result << endl; // ✅️ 输出 10,不是 7! return 0; }解释:result引用的变量一直存在,所以这里不会存在类似野指针的问题,而打印结果为10,也是正确的,因为sum从7变成了10
此时我们再用之前全为9的数组来验证一下,如果result结果还为7,则代表result引用的空间一直都存在:
#include <iostream> using namespace std; int& add(int x, int y) { static int sum; sum = x + y; return sum; } // 新增一个函数,用来“污染”之前 add 函数占用的栈空间 void overwriteStack() { int arr[10] = { 9, 9, 9, 9, 9, 9, 9, 9, 9, 9 }; // 随意填充数据 for (int i = 0; i < 10; i++) { cout << arr[i] << " "; // 防止编译器优化掉 arr } cout << endl; } int main() { int& result = add(3, 4); // result 指向一块已释放的栈内存(值应该是7) overwriteStack(); // 这块栈内存现在被新的函数调用覆盖了 但sum变量仍存在 生命周期已被static延长 cout << result << endl; // result引用空间 仍存在 为7 return 0; }解释:证明了result引用的空间仍存在!
3:错误使用static的情况
需要注意,static所处的那一行,只在第一次执行到它时生效一次,后面编译器都会跳过改行,因之后程序的整个生命周期内,static所处行修饰的变量都一直存在,这也就意味着,如果你把sum的赋值也写在了static修饰sum的那一行,那sum只会被赋值一次,后序的add调用无法让x+y的新值赋给sum
可以理解为:static 变量只“出生”一次,但可以“被赋值”无数次;若把sum初始化写在sum定义行,等于只给了sum一次改变的机会;把sum赋值单独拎出来,等于给了它无数次改变的机会。
❌️错误写法:
#include <iostream> using namespace std; int& add(int x, int y) { static int sum = x + y; // ✅ 静态变量,生命周期随程序, ❌️但sum恒为第一次的x+y return sum; // ✅ 安全:返回的是活着的变量 } int main() { int& result = add(3, 4); // result 绑定到 sum,sum = 7 add(5, 5); // sum 被覆盖为 10 cout << result << endl; // ❌️ 输出 7,不是 10! return 0; }解释:
①:即使正确的使用引用作为函数的返回值,但是把赋值写在了static所处行,导致sum只会被赋值一次,后序add(5, 5); 结果10也给不到static;
②:把赋值和static定义写在一行的情况也有,就是不再对static修饰的值进行更改的时候,所以不能说哪种写法对,只能根据要求而定!
4:函数值返回触发临时变量机制
总结:一张表说清函数采取值返回触发临时变量机制的所有情况
| 返回对象生命周期 | 函数返回类型 | 是否产生临时变量 | 典型示例 |
|---|---|---|---|
| 短(局部变量) | 值返回int | 是(必须的,安全需要) | return localVar; |
| 短(局部变量) | 引用返回int& | 是灾难(返回悬空引用) | return localVar;❌ |
| 长(全局/静态) | 值返回int | 是(语法规定,拷贝值) | return global; |
| 长(全局/静态) | 引用返回int& | 否(直接返回别名) | return global; |
解释:
①:不管你返回变量是否随栈帧销毁而销毁,只要采取值返回,编译器为了安全起见,就会触发临时变量机制,100%确保你的值被返回,尽管变量是全局的静态的,也会如此
②:若采取引用返回,此时的确不会生成临时变量,但是如果返回变量会销毁,则悬空引用,类似空指针的错误,只有返回变量是全局或静态,再搭配上引用返回,才是正确的!
六:指针 VS 引用
我们在最开始说过,引用语法上没有开辟空间,但底层上和指针是一致的,也是开辟了空间的
引用和指针的汇编代码对比:完全一致
指针和引用的区别,面试常问题:
①. 引用概念上定义一个变量的别名,指针存储一个变量地址。
②. 引用在定义时必须初始化,指针没有要求
③. 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
④. 没有NULL引用,但有NULL指针
⑤. 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节)
⑥. 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
⑦. 有多级指针,但是没有多级引用
⑧. 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
⑨. 引用比指针使用起来相对更安全
在后续的C++学习中,引用的作用是指针替代不了的,无论是拷贝构造还是任何类中成员函数使用引用作为参数,引用都是更优秀的!
📌 [ 作者 ] shylyly
📃 [ 首次发布 ] 2026.7.14
❌ [ 最新修改 ] 暂无
📜 [ 声明 ] 由于笔者水平有限,文中难免有疏漏或不妥之处,还望读者不吝赐教