【C++要笑着学】引用的概念 | 引用的应用 | 引用的探讨 | 常引用(一)

简介: 本章将对C++的基础,引用 部分的知识进行讲解。

💭 写在前面



本章将对C++的基础,引用 部分的知识进行讲解。


有些地方为了能够加深理解,我们会举几个比较有意思的栗子,在讲解的同时会适当的整活。


我觉得这篇博客是我目前写的最好的一篇,希望大家能看到底!


如果觉得文章不错,可以 "一键三连" 支持一下博主!你们的关注就是我更新的最大动力!


Ⅰ. 引用的概念


0x00 引入话题

不知道大伙知不知道 "抓捕周树人跟我鲁迅有什么关系"  这个梗 ~

b0cac613d85b4abfd35f7e6d969fb7c9_bc2d75887fc6466c8116a19553044f4f.png

这段是2018年电视剧《楼外楼》中鲁迅先生的一段台词。剧中一个没文化的军官带着一批人要来抓捕周树人,鲁迅让他们拿出搜捕令,他们拿了出来,鲁迅看过之后就说:抓捕周树人和我鲁迅有什么关系?于是这群人都以为是抓错人了,就走了。


这一段其实是在嘲讽他们没有文化,连作家的笔名都不知道。


"抓捕周树人跟我鲁迅有什么关系" ,当然有关系了!哈哈哈哈哈哈。

0b4f7b77280efe341481446bc26e4e57_32e8c56a19c445ee94b996c3dd3fdd70.png


后来这个军官才知道鲁迅是周树人的笔名,他们要抓的人正是鲁迅。


🔺 这个 "笔名" 其实就是引用,我们继续往下学习。


0x01 什么是引用

😂 第一次接触 "引用" 的概念时,直接看词去理解,真的会让人一脸懵逼……


但是如果用 "取别名" 或 "取绰号" 来理解,就没有那么难以理解了。

65f08a59087d9002272f35121ae8eb14_6f2c22f6cb9844efbac50881eb230bd2.png


🤔 但是如果用 "取别名" 或 "取绰号" 来理解,就没有那么难以理解了。


📚 概念:引用就是给一个已经存在的变量取一个别名。


📜 语法:数据类型&  引用名 =  引用实体;


                              👆 这里的&可不是取地址啊!它是放在数据类型后面的&,一定要区分开来!


💬 代码演示:


#include <iostream>
using namespace std;
int main(void)
{
    int ZhouShuRen = 1881;
    int& LuXun = ZhouShuRen;  // 鲁迅就是周树人的引用
    cout << ZhouShuRen << endl;
    cout << LuXun << endl;
    return 0;
}

🚩 运行结果: 1881 1881


🔑 解读:


引用在语法层,我们要理解这里没有开新空间,就是对原来的取了一个新名称而已。

7e00dcafdac4e98244fee22996029da0_9292c0ac56124e81b5b549db776a831b.png


📌 注意事项:


① 引用并不是新定义一个变量,只是给一个变量取别名。


② 编译器不会为引用的变量开辟内存空间,它和它引用的变量会共用同一块内存空间。

0c5e0dd12c12738809bb4b1a590c1a47_6ce8577110be46a8954ff60cb639653e.png


0x02 引用的特性

引用在定义时必须初始化!


初始化时必须要指定清楚,你到底是要给谁取别名。


含糊不清是不行的,你都不知道要给谁取别名,你取他干甚呢?


💬 又到了大伙最爱的踩坑环节:

#include<iostream>
using namespace std;
int main(void)
{
    int a = 10;
    int& b;      // ❌
    return 0;
}

fafa349e72e67f6465ee85620f847f34_f4759f50d79048be8f193e0e2c0491d1.png

一个变量可以有多个引用。


一个人当然可以有多个绰号,所以一个变量也可以有多个别名。


💬 代码演示:(川普  川建国  懂王)


#include <iostream>
using namespace std;
int main(void)
{
    int Trump = 2333;           // 变量
    int& ChuanJianGuo = Trump;  // 引用1
    int& DongWang = Trump;      // 引用2
    return 0;
}

引用一旦引用了一个实体,就不能引用其他实体了。


int main(void)
{
  int a = 10;
  int& ra = a;
  int b = 20;
  ra = b;       // ?
  return 0;
}

(这里取名为 ra,因为引用的英文是 reference,所以我后面命名变量时会简写为 r,或者 ref 来代表引用)


❓ 问号处是什么意思呢?这里是让 ra 变成 b 的别名,还是把 b 的值赋值给 ra 呢?


💡 这里是赋值,我们打开监视窗口看一下:

8f8f4a25f56335e01b7f0b639fa60b32_8c77913af4dc4cf0996091df69ccafaf.png


🔺 引用是不会变的,我们定义它的时候它是谁的别名,就是谁的别名了。


以后就不会改了,它是从一而终的!!!


💬 引用和指针是截然不同的,指针是可以改变指向的:


int main(void)
{
    int a = 10;
    int* p1 = &a;
    int b = 20;
    p1 = &b;  // 改变指针指向
    return 0;
}

🔑 解析:指针在这里就像极了渣男!

d71759aba95481eb66eeda0e4b9dd898_6038058d56a0497698d4cc9999f0f602.png


📚 这里再提一句,引用的底层其实就是指针。


你可以这么理解,引用他不想像以前那样做渣男了,于是回炉重造!


《重生之我不是渣男》,开始一生只爱一个人了!



Ⅱ. 引用的应用


0x00 引入

 平常这么写其实没什么意义:


int a = 10;
int& ra = a;

❗  它真正有用的地方在于它能够做参数和做返回值。


0x01 引用做参数

我们在C语言教学中讲过 Swap 两数交换的三种方式。


我们当时用的最多的就是利用临时变量去进行交换。


如果把它写成函数形式就是这样:


void Swap(int* px, int* py) {
    int tmp = *px;
    *px = *py;
    *py = tmp;
}
int main(void)
{
    int a = 10;
    int b = 20;
    Swap(&a, &b);  // 传址
    return 0;
}

这里我们调用 Swap 函数需要传地址,


因为形参是实参的一份临时拷贝,改变形参并不会对实参产生实质性的影响。


💬 但是,我们学了引用之后我们就可以这么玩:

void Swap(int& ra, int& rb) {
    int tmp = ra;
    ra = rb;
    rb = tmp;
}
int main(void)
{
    int a = 10;
    int b = 20;
    Swap(a, b);  // 这里既没有传值,也没有传地址,而是传引用
    return 0;
}

🔍 监视结果如下:

dfec975724e2e96c3c4727fc2fb6a495_b47ec1fb0b284201b1fe3faa8a95fe26.png


❓ 是怎么做到交换的?


🔑 我们知道,形参是定义在栈帧里面的。


实际调用这个函数的时候,才会给 ra 和 rb 开空间。调用这个函数的时候,把实参传给形参。


那什么时候开始定义的?实参传给形参的时候开始定义的。


ra 是 a 的别名,rb 是 b 的别名,所以 ra 和 rb 的交换,就是 a 和 b 的交换。


因此,我们利用这一特点,就可以轻松实现两数的交换。


🔺 我们来梳理一下,顺带复习一下之前讲的函数重载。


💬 现在我们一共学了三种传参方式:传值、传地址、传引用。


#include<iostream>
using namespace std;
void Swap(int x, int y) {
    int tmp = x;
    x = y;
    y = tmp;
}
void Swap(int* px, int* py) {
    int tmp = *px;
    *px = *py;
    *py = tmp;
}
void Swap(int& rx, int& ry) {
    int tmp = rx;
    rx = ry;
    ry = tmp;
}
int main(void)
{
    int a = 10;
    int b = 20;
    Swap(&a, &b);
    Swap(a, b);  // 报错 ❌  
    return 0;
}

这里 Swap(a,b) 为什么会报错呢?


这三个 Swap 是可以构成函数重载的,


只要不影响它的函数名修饰规则,就不会构影响!


换言之,修饰出来的函数名不一样,就支持重载!


void Swap(int x, int y);              _Z4swapixiy
void Swap(int* px, int* py);       _Z4swaprxry
void Swap(int& rx, int& ry);       _Z4swappxpy

但是 Swap(a,b) 调用时存在歧义。调用不明确!


它不知道调用哪一个,是传值还是传引用,所以会报错。


当时再讲数据结构单链表的时候用的是二级指针,当时没有采用头结点的方式。


那么要传指针的地址,自然要用二级指针的方式接收。


现在我们学了引用,我们就可以试着用引用的方法来解决了(这里我们把 .c 改为 .cpp)


任何类型都是可以取别名的,指针也不例外:


int a = 10;
int& ra = a;
int* pa = &a;
int*& rpa = pa

我们来看如何用引用的方法来实现!


💬 SList.h:


#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int SLNodeDataType;
typedef struct SingleListNode {
    SLNodeDataType data;           // 用来存放节点的数据
    struct SingleListNode* next;   // 指向后继节点的指针
} SLNode;                          
void SListPrint(SLNode* pHead);
void SListPushBack(SLNode*& rpHead, SLNodeDataType x);
// ... 略

💬 SList.cpp:


#include "SList.h"
/* 打印 */
void SListPrint(SLNode* pHead) {
    SLNode* cur = pHead;
    while (cur != NULL) {
        printf("%d -> ", cur->data);
        cur = cur->next;
    }
    printf("NULL\n");
}
/* 创建新节点 */
SLNode* CreateNewNode(SLNodeDataType x) {
    //创建,开辟空间
    SLNode* new_node = (SLNode*)malloc(sizeof(SLNode));
    //malloc检查
    if (new_node == NULL) {
        printf("malloc failed!\n");
        exit(-1);
    }
    //放置
    new_node->data = x; //存传入的数据
    new_node->next = NULL; //next默认置空
    return new_node; //递交新节点
}
/* 尾插(指针的引用) */
void SListPushBack(SLNode*& rpHead, SLNodeDataType x) {
    //创建新节点
    SLNode* new_node = CreateNewNode(x);
    //如果链表是空的
    if (rpHead == NULL) {
        //直接插入即可
        rpHead = new_node;
    }
    else {
        //找到尾结点
        SLNode* end = rpHead;
        while (end->next != NULL) {
            end = end->next; //令end指向后继节点
        }
        //插入
        end->next = new_node;
    }
}


🔑 解读: 这里的 SLNode*& rpHead 就是 pHead 的一个别名。


💬 Test.cpp:


#include "SList.h"
// 这里我们不传二级指针了。
//void TestSList1()
//{
//  SLNode* pList = NULL;
//  SListPushBack(&pList, 1);
//  SListPushBack(&pList, 2);
//  SListPushBack(&pList, 3);
//  SListPushBack(&pList, 4);
//
//  SListPrint(pList);
//}
// 使用引用的方法:
// 我们传 指针的 引用!
void TestSList2()
{
  SLNode* pList = NULL;
  SListPushBack(pList, 1);
  SListPushBack(pList, 2);
  SListPushBack(pList, 3);
  SListPushBack(pList, 4);
  SListPrint(pList);
}
int main()
{
  TestSList2();
  return 0;
}


🔑 解读:这里我们采用引用的方法,调用 SListPushBack 时传递的就是 pList 的引用。


这里补充一下,为了能够更简单地表示,有些书上还有这种写法。


定义链表结构的时候 typedef 多定义一个 pSLNode*


#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
typedef int SLNodeDataType;        // SLNodeDataType == int
typedef struct SingleListNode {
    SLNodeDataType data;           // 用来存放节点的数据 int data
    struct SingleListNode* next;   // 指向后继节点的指针
} SLNode, *pSLNode; 
void SListPrint(pSLNode pHead);
void SListPushBack(pSLNode& rpHead, SLNodeDataType x);
// 这么一来,就会出现这种写法 👆
// 很多书上的写法是这样的,我们一开始讲链表的时候
// 因为当时还没有出C++的教学,所以没有用这种方法。

0x02 传值返回

讲引用返回前,我们需要做一点点铺垫。


💬 传值返回:


int Add(int a, int b) {
    int c = a + b;
    return c;
}
int main(void)
{
    int ret = Add(1, 2);
    cout << ret << endl;
    return 0;
}

🔑 解读:

8bb4c9778da99fc1062b56917aae0ad5_0f0fba7b8ef441bea30e8f8a7352a220.png


这里 return 的时候会生成一个临时变量(c 为 3)


将 3 复制给这个临时变量,然后返回给 ret


如果我们直接把 c 交给 ret,就会出现一些问题。


如果直接取 c 给 ret,取到的是 3 还是 随机值,就要取决于栈帧是否销毁空间!


这个时候严格来说,其实都是非法访问了。


因为这块空间已经还给操作系统了,这就取决于编译器了。


有的编译器会清,有的编译器不会清,这就太玄学了!


所以,在这中间会生成一个临时变量,来递交给 ret 。


而不是直接用 c 作为返回值,造成非法访问。


所以这里不会直接用 c 作为返回值,而是生成一个临时变量。


那么问题来了,这个临时变量是存在哪里的呢?


① 如果 c 比较小(4或8),一般是寄存器来干存储临时变量的活。


② 如果 c 比较大,临时变量就会放在调用 Add 函数的栈帧中。


🔺 总结:所有的传值返回都会生成一个拷贝


(这是编译器的机制,就像传值传参会生成一份拷贝一样)


我们用 VS 来反汇编操作一下,加深理解:

373aebc0e91e389845d3c8911a89805e_fb0a80136dd24272bfb1e4427461bb62.png


🔑 解读:我们可以清楚的看到,确实是通过寄存器将 a + b 的结果交给 ret 的。


0x03 引用做返回值

我们已经知道,这里会生成一个临时变量了。


我们现在回到正题,我们来试试引用的返回。


💬 体会下面的代码:


#include <iostream>
using namespace std;
int& Add(int a, int b) {
    int c = a + b;
    return c;
}
int main(void)
{
    int ret = Add(1, 2);
    cout << ret << endl;
    return 0;
}

🔑 引用返回的意思就是,不会生成临时变量,直接返回 c 的别名。

3be9de5c4b8949fc08e8632d2ccd9bf3_669107111b9040cfa47261e46d30f077.png


❌ 这段代码存在的问题:


① 存在非法访问,因为 Add(1, 2) 的返回值是 c 的引用,所以 Add 栈帧销毁后,会去访问 c 位置空间。


② 如果 Add 函数栈帧销毁,清理空间,那么取 c 值的时候取到的就是随机值,给 ret 就是随机值,当前这个取决于编译器实现了。VS 下销毁栈帧,是不清空间数据的。


栈帧:C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。


既然不清空间数据,那还担心什么呢?


💭 我们来看看下面这种情况:


#include <iostream>
using namespace std;
int& Add(int a, int b) {
    int c = a + b;
    return c;
}
int main(void)
{
    int& ret = Add(1, 2);
    cout << ret << endl;
    Add(10, 20);
    cout << ret << endl;  // 这里ret变成30了
    return 0;
}


🚩 运行结果:

641155134a8de98b270de6dbd7585f70_6af7e09c0ed943629cce8838afd3d1b0.png


🔑 解读:我们并没有动 ret,但是 ret 的结果变成了 30,因为栈帧被改了。


当再次调用 Add 时,这块栈帧的 "所有权" 就不是你的了。


我函数销毁了,栈帧就空出来了,新的函数覆盖了之前那个已经销毁的栈帧,


所以 ret 的结果变成 30 了。


似乎还是不太好理解,为了加深印象,我举个形象(奇葩)的例子:


其实,操作系统对内存空间的管理就像是房东一样。


我们使用的内存就好比是找房东租房子一样。


建立栈帧,函数调用完成后,把房子还给房东了。


但是你偷偷地把行李箱留在了房间里,


如果恰好没有人来住这个房间,你去取这个行李箱时完全没有问题的。


但是如果有人住了,新的租客没动你的行李箱,也不会有问题。


就怕这个新租客把你放在这的行李箱给丢了,甚至直接把你的行李箱占为己有了。


把你里面的衣服(数据)都给扔了,还把自己臭袜子放进去了。


这时你再回去取你的行李箱,取到的也只有臭袜子了,也不是你的衣服了。

d3674d1e6a1dc85d8324e891b0a560eb_1a33eeb973e44400872682a8318f70d4.png


🤣 呼呼啦啦说一大堆,结论就是:不要轻易使用引用返回!


❓ 那引用返回有什么存在的意义呢?等我们后面讲完类和对象后再细说。


🔺 总结:


日常当中是不建议用引用返回的,如果函数返回时,出了函数的作用域,


如果返回对象还未还给操作系统,则可以使用引用返回,如果已经还给操作系统了,


就不要用引用返回了,老老实实传值返回就行了。


通俗点说就是 —— 看返回对象还在不在栈帧内,在的话就可以使用引用返回。


💬 举个例子:静态变量,全局变量,出了作用域不会销毁


int& Count() {
    static int n = 0;
    n++;
    // ...
    return n;
}

📌 注意事项:临时变量具有常性


#include <iostream>
#define N 10
using namespace std;
int& At(int i) {
    static int arr[N];
    return arr[i];  // 返回的是数组的第i个的引用(别名)
}
int main(void)
{
    // 写
    for (size_t i = 0; i < N; i++) {
        At(i) = 10 + i;  // 依次给 11 12 13 14…… 给 At
    }
    // 读
    for (size_t i = 0; i < N; i++) {
        cout << At(i) << " ";  // 获取值,但是只是打印
    }
    cout << endl;
    return 0;
}


🚩 运行结果如下:

9bef0fdfa2463f361fc288b2a8949024_066878e78ee845178ab5d5424d3bb4f4.png


具有常性,临时变量是右值(不可被修改),可以读但不能修改。

e0e351215227d2e24eb8711a5baac7c9_e29f12ebd3204c98a1875becca171bbc.png


相关文章
|
21天前
|
存储 并行计算 安全
C++多线程应用
【10月更文挑战第29天】C++ 中的多线程应用广泛,常见场景包括并行计算、网络编程中的并发服务器和图形用户界面(GUI)应用。通过多线程可以显著提升计算速度和响应能力。示例代码展示了如何使用 `pthread` 库创建和管理线程。注意事项包括数据同步与互斥、线程间通信和线程安全的类设计,以确保程序的正确性和稳定性。
|
1月前
|
存储 编译器 C++
【C++篇】揭开 C++ STL list 容器的神秘面纱:从底层设计到高效应用的全景解析(附源码)
【C++篇】揭开 C++ STL list 容器的神秘面纱:从底层设计到高效应用的全景解析(附源码)
53 2
|
2月前
|
编译器 C++
【C++核心】函数的应用和提高详解
这篇文章详细讲解了C++函数的定义、调用、值传递、常见样式、声明、分文件编写以及函数提高的内容,包括函数默认参数、占位参数、重载等高级用法。
22 3
|
1月前
|
程序员 C++ 开发者
C++入门教程:掌握函数重载、引用与内联函数的概念
通过上述介绍和实例,我们可以看到,函数重载提供了多态性;引用提高了函数调用的效率和便捷性;内联函数则在保证代码清晰的同时,提高了程序的运行效率。掌握这些概念,对于初学者来说是非常重要的,它们是提升C++编程技能的基石。
21 0
|
3月前
|
存储 算法 C++
C++ STL应用宝典:高效处理数据的艺术与实战技巧大揭秘!
【8月更文挑战第22天】C++ STL(标准模板库)是一组高效的数据结构与算法集合,极大提升编程效率与代码可读性。它包括容器、迭代器、算法等组件。例如,统计文本中单词频率可用`std::map`和`std::ifstream`实现;对数据排序及找极值则可通过`std::vector`结合`std::sort`、`std::min/max_element`完成;而快速查找字符串则适合使用`std::set`配合其内置的`find`方法。这些示例展示了STL的强大功能,有助于编写简洁高效的代码。
48 2
|
3月前
|
存储 搜索推荐 Serverless
【C++航海王:追寻罗杰的编程之路】哈希的应用——位图 | 布隆过滤器
【C++航海王:追寻罗杰的编程之路】哈希的应用——位图 | 布隆过滤器
36 1
|
4月前
|
存储 安全 C++
浅析C++的指针与引用
虽然指针和引用在C++中都用于间接数据访问,但它们各自拥有独特的特性和应用场景。选择使用指针还是引用,主要取决于程序的具体需求,如是否需要动态内存管理,是否希望变量可以重新指向其他对象等。理解这二者的区别,将有助于开发高效、安全的C++程序。
32 3
|
3月前
|
存储 编译器 C++
C++多态实现的原理:深入探索与实战应用
【8月更文挑战第21天】在C++的浩瀚宇宙中,多态性(Polymorphism)无疑是一颗璀璨的星辰,它赋予了程序高度的灵活性和可扩展性。多态允许我们通过基类指针或引用来调用派生类的成员函数,而具体调用哪个函数则取决于指针或引用所指向的对象的实际类型。本文将深入探讨C++多态实现的原理,并结合工作学习中的实际案例,分享其技术干货。
74 0
|
4月前
|
JSON Go C++
开发与运维C++问题之在iLogtail新架构中在C++主程序中新增插件的概念如何解决
开发与运维C++问题之在iLogtail新架构中在C++主程序中新增插件的概念如何解决
45 1
|
3月前
|
JSON Android开发 C++
Android c++ core guideline checker 应用
Android c++ core guideline checker 应用