《C++初阶之入门基础》【普通引用 + 常量引用 + 内联函数 + nullptr】

【普通引用 + 常量引用 + 内联函数 + nullptr】目录

往期《C++初阶》回顾:

/------------ 入门基础 ------------ /
【C++的前世今生】
【命名空间 + 输入&输出 + 缺省参数 + 函数重载】

前言:

(๑˃̵ᴗ˂̵)و hi~ 亲爱的小伙伴们!新的一周开始啦~🌞,让我们在 C++ 的学习之路上继续勇敢前行,努力加油吧!💪✨
🌈 本篇博客的核心内容是对 C++ 中的 "引用" 进行深度解析,这部分内容占据了博客的主要篇幅。

希望能通过详细的讲解,让大家重新认识并深入理解 "引用" 这一重要概念~(๑¯◡¯๑)🧸

---------------普通引用---------------

什么是引用?

引用(Reference):是一种特殊的变量,它充当另一个对象的别名。

  • 本质 :引用是变量的"别名",与原始变量共享同一内存地址。

  • 语法数据类型 &引用名 = 原变量;

  • 使用场景 :引用提供了一种直接访问和操作目标对象的方式,常用于函数参数传递返回值优化以及简化代码

  • 关键特性

    • 必须在初始化时绑定到一个变量(不能为 NULL
    • 一旦绑定后不可更改指向其他变量
    • 对引用的所有操作等同于对原变量的操作

注意:引用不是新定义一个变量,而是给已存在变量取了⼀个别名,编译器不会为引用变量开辟内存空间, 它和它引用的变量共用同一块内存空间。

怎么使用引用?

引用的使用场景非常多,应用价值也非常的大,所以引用值得我们花很多的时间进行学习,对于C++学习的初阶阶段这里博主就挑选三个常见的使用场景进行学习:

使用场景一:使用引用起别名(基本使用)

使用场景二:使用引用作为函数的参数

  • 好处一:避免拷贝,提高性能
  • 好处二:可以修改实参

使用场景三:使用引用作为函数返回值

  • 好处一:避免不必要的拷贝
  • 好处二:支持左值修改

使用场景一:引用起别名(基本使用)

cpp 复制代码
/*--------------------------引用的使用--------------------------*/
#include<iostream>  
using namespace std;

int main() 
{
    int a = 0; 
	/*------------------------使用场景一:使用引用起别名(基本使用)----------------------------------*/
    /*--------------案例1:同一个变量可以定义多个引用--------------*/

    //引用必须在声明时初始化,且不能改变引用的对象
    int& b = a; // 定义整型引用b,它是a的别名(与a绑定)
    int& c = a; // 定义整型引用c,它也是a的别名(与a绑定)



    /*--------------案例2:可以定义引用的引用--------------*/

    //也可以给别名b取别名,d相当于还是a的别名
    int& d = b; //定义整型引用d,它是b的别名,而b是a的别名,因此d也是a的别名


    ++d;  //通过引用d对a的值进行自增操作(相当于++a)
    //这里取地址我们看到是一样的
    //因为引用是变量的别名,所以它们的地址相同
    cout << &a << endl; // 输出变量a的地址
    cout << &b << endl; // 输出引用b的地址(与a相同)
    cout << &c << endl; // 输出引用c的地址(与a相同)
    cout << &d << endl; // 输出引用d的地址(与a相同)

    return 0;
}

使用场景二:引用作为函数的参数

我相信这时候大家心里一定在嘀咕:引用作函数的参数。

好处 :1.避免拷贝,提高性能 2.可以修改实参。哎,这不是和指针一模一样吗?我们真的还有必要再学习一个"引用"吗?

确实引用作为函数的参数简直就和指针一模一样(当然还是有很多的不一样之处的)


下面的程序分别调用了函数的参数是"引用"和"指针"的交换函数,通过对比交换函数的两种实现方式,我们可以更加深入的了解两者作函数的参数的区别

cpp 复制代码
/*--------------------------引用的使用--------------------------*/
#include <iostream>
using namespace std;
/*------------------------使用场景二:使用引用作为函数的参数----------------------------------*/

/*--------------使用"引用"实现两个变量值的交换--------------*/
/**
 * @brief 交换两个整数的值(引用版本)
 * @param rx 第一个整数的引用(别名),函数内修改会影响实参
 * @param ry 第二个整数的引用(别名),函数内修改会影响实参
 * @note 使用引用传参,避免值拷贝,直接操作原始变量
 */
void Swap1(int& rx, int& ry)  //参数为引用类型
{
    int tmp = rx;
    rx = ry;
    ry = tmp;
}

/*--------------使用"指针"实现两个变量值的交换--------------*/
/**
 * @brief 交换两个整数的值(指针版本)
 * @param rx 第一个整数的指针,函数内通过指针修改实参
 * @param ry 第二个整数的指针,函数内通过指针修改实参
 * @note 使用指针传参,需解引用操作,直接操作原始变量
 */
void Swap2(int* ra, int* rb)  //参数为指针类型
{
    //安全检查:防止空指针
    if (ra == nullptr || rb == nullptr)
    {
        cerr << "Error:null pointer";
        return;
    }
    int tmp = *ra;
    *ra = *rb;
    *rb = tmp;
}


int main()
{
    int x = 0, y = 1;

    /*-----------------调用形参是引用的函数进行交换-----------------*/
    cout << "调用形参是引用的函数进行交换" << endl;
    // 打印交换前的值
    cout << "交换前: x = " << x << " y = " << y << endl;

    //调用交换函数
    Swap1(x, y);  // 传递变量的引用,函数内直接修改x和y的值

    // 打印交换后的值
    cout << "交换后: x = " << x << " y = " << y << endl;


    /*-----------------调用形参是指针的函数进行交换-----------------*/
    cout << "调用形参是指针的函数进行交换" << endl;
    // 打印交换前的值
    cout << "交换前: x = " << x << " y = " << y << endl;

    //调用交换函数
    Swap2(&x, &y);  // 传递变量的指针,函数内直接修改x和y的值

    // 打印交换后的值
    cout << "交换后: x = " << x << " y = " << y << endl;

    return 0;
}

通过对比Swap()的引用实现和指针实现,我们可以总结出:引用指针两者函数的参数不同

特性 引用版本 指针版本
参数类型 int&(引用) int*(指针)
传参方式 直接传变量名(隐式引用) 需显式取地址(&x
内部访问 直接使用变量名(自动解引用) 需手动解引用(*rx
空值安全性 无空引用问题 需检查 nullptr
代码简洁性 更简洁(无需解引用操作) 稍显繁琐(需写*&

总结 :通过上面的对比,我们发现:使用引用作为函数的参数在一些细节上要优于使用指针作为函数的参数

所以:以后当我们写的函数的参数可以使用引用的话,我们都尽量不会再去使用指针了。

使用场景三:引用作为函数的返回值

这里我们先回顾以下:引用作为函数的返回值的两点主要的好处

好处一避免拷贝,提高性能

看到这个好处一,我想相信有些小伙伴们是感觉到有点奇怪的?

之前引用作为函数的参数,可以避免拷贝以提高性能,我们可能还比较容易理解

毕竟我们在学习C语言的时候格外强调需要注意的一点就是:调用函数时候,传参可以分为:值传递地址传递 两中,其中:值传递传递的是实参的拷贝副本,会有一定的性能消耗

那这里函数的返回值......?


想要完全的理解这个问题其实是需要后面的知识做铺垫的(吐槽:哎C++的学习就是这样的,很多知识前后是有很强的关联性的,所以我们必须持续的学习,慢慢的我们才能在某一天开悟的,这里博主就尝试简单的为大家讲解以下为什么引用可以做到这一点)


首先 ,我们要明确调用函数时有两个重要的过程:函数参数的传递 + 函数返回值的返回

其实这两个过程都涉及了:"复制拷贝 "(:不只是函数调用的传参阶段涉及)

其次,你要注意我这里说的"复制和拷贝"是两种不同的意思(尽管在现实生活中我们认为这两者是没用区别的,但是在C++中我们还是要将两者进行区分的,目的是防止和后面的概念搞混)(所以上面我们的有些说法也是不正确的,只是作为过渡而已)

  1. 当使用内置类型的变量作为实参使用值传递调用函数时

  2. 当函数的返回值内置类型的变量时(没有使用:指针、引用)

针对于上面的两种情况:都要创建变量的副本,我们称创建副本的过程为 复制

  1. 当使用自定义类型的对象作为实参使用值传递调用函数时
  2. 当函数的返回值自定义类型的对象时(没有使用:指针、引用)

针对于上面的两种情况:都要创建对象的副本,我们称创建副本的过程为 拷贝


这时候一定会有小伙伴们会说:至于吗?就为了一个名字搞得那么复杂。

哈哈哈,有这样的疑问很正常,接下来我就告诉你,我们为什么要这么大费周折的命名

对于内置类型的变量

在调用函数的时候无论是传参还是返回值:都仅仅是复制了一份副本,其实并不会产生什么性能上的开销

原因是:复制的内置类型的变量的字节数并不是很大),然而我们使用指针或引用作为函数的参数其实仅仅是为了让函数可以直接访问和修改调用者提供的实参变量。

自定义类型的对象

在调用函数的时候无论是传参还是返回值:都会发生拷贝构造(创建临时对象)这种行为对性能的开销还是很大的

  • 原因一 :尤其对大对象(如std::vector)性能损耗显著,
  • 原因二:面对复杂的对象时可能需要进行深拷贝,需要进行堆内存分配,这也是高成本操作)

所以:在面向对象阶编程时,函数的形参和返回值都是能用引用就用引用(相信你在看C++的文档的时候已经发现了很多的标准库函数都使用了引用,这就是最好的佐证)


好处二支持左值修改

这里我们就重新实现一下我们在《数据结构初阶》中实现的栈的接口函数:"取栈顶元素"的操作,来感受一下引用的这个好处吧!

cpp 复制代码
/*----------------------------声明要包含的头文件----------------------------*/
#include<iostream>  
#include <assert.h>
using namespace std; 

/*----------------------------定义栈的存储结构----------------------------*/
// 定义栈元素的数据类型为int(便于后续修改类型)
typedef int STKDataType;

// 栈的结构体定义
typedef struct Stack
{
    STKDataType* a;    // 动态数组,用于存储栈元素
    int top;          // 栈顶指针(指向当前栈顶元素的下一个位置)
    int capacity;     // 栈的当前容量
}STK;

/*----------------------------实现栈的接口函数----------------------------*/
//1.栈的"初始化"操作
//2.栈的"入栈"操作
//3.栈的"获取栈顶元素"操作

/**
 * @brief 初始化栈
 * @param rs 栈的引用(直接操作原栈)
 * @param n 初始容量,默认为4
 */
void STKInit(STK& pstk, int n = 4)
{
    pstk.a = (STKDataType*)malloc(n * sizeof(STKDataType));
    if (pstk.a == NULL) // 内存分配失败检查
    { 
        perror("malloc fail");
        exit(1);
    }
    pstk.top = 0;       //初始栈顶位置为0(空栈)
    pstk.capacity = n;  //设置初始容量
}

/**
 * @brief 入栈操作
 * @param rs 栈的引用
 * @param x 要压入的元素
 */
void STKPush(STK& pstk, STKDataType x)
{
    assert(&pstk); // 确保栈对象有效

    // 检查是否需要扩容
    if (pstk.top == pstk.capacity)
    {
        int newcapacity = pstk.capacity * 2; //计算新容量
        STKDataType* tmp = (STKDataType*)realloc(pstk.a, newcapacity * sizeof(STKDataType));
        if (tmp == NULL) // 扩容失败检查
        {
            perror("realloc fail");
            return;
        }
        pstk.a = tmp;            // 更新数组指针
        pstk.capacity = newcapacity; // 更新容量
    }

    pstk.a[pstk.top] = x; // 元素入栈
    pstk.top++;         // 栈顶指针后移
}

/**
 * @brief 获取栈顶元素(返回引用可修改)
 * @param rs 栈的引用
 * @return 栈顶元素的引用
 */
int& STKTop(STK& pstk)
{
    assert(pstk.top > 0); // 确保栈不为空

    return pstk.a[pstk.top - 1]; // 返回实际栈顶元素(注意top-1)
}

int main()
{
    /*----------1.创建栈 + 初始化----------*/
    STK st1;          // 声明栈变量
    STKInit(st1);     // 初始化栈(默认容量4)

    /*----------2.入栈----------*/
    STKPush(st1, 1);  // 压入元素1
    STKPush(st1, 2);  // 压入元素2

    /*----------3.获取栈顶的元素----------*/
    cout << STKTop(st1) << endl; // 输出栈顶元素:2

    
    
    
    /*----------4.修改栈顶的元素----------*/
    
    STKTop(st1) += 10; //好处二:支持左值修改
    
    
    cout << STKTop(st1) << endl; // 输出修改后的栈顶元素:12
    return 0;
}

引用作为函数的返回值有哪些注意事项?

函数返回引用时要注意生命周期

  • 不能返回局部变量的引用(会导致悬空引用)

    cpp 复制代码
    /*--------------引用作为函数的返回值的注意事项--------------*/
    #include <iostream>
    using namespace std;
      
    /*-----------使用引用作为函数的返回值-----------*/
    int& Func()
    {
    	int a = 10; 
      
    	return a; //返回的是局部变量,函数结束后引用失效
    }
      
    int main()
    {
    	int& res = Func();
    	cout << res;
    	return 0;
    }

注意 :⚠️ 永远不要返回局部变量的引用! ⚠️

确保返回的引用绑定到生命周期足够长的对象(:静态变量、全局变量、参数引用)

毕竟引用和指针是好哥俩,而我们学习C语言的时候知道:返回局部变量的指针是造成野指针的三种情况之一,虽然嘛引用相较于指针更加的安全,但是这种间接导致引用的对象不存在,出现野引用的情况我们还是要规避的。

但是 :可以返回静态变量全局变量传入的引用参数
1. 返回静态变量

cpp 复制代码
/*--------------引用作为函数的返回值的注意事项--------------*/
#include <iostream>
using namespace std;

/*-----------使用引用作为函数的返回值-----------*/
int& Func()
{
	static int a = 10; //静态变量

	return a; //返回的是静态变量,静态变量生命周期持续到程序结束
}

int main()
{
	int& res = Func();
	cout << "访问返回的静态变量引用: " << res << endl;

	res = 20; //通过引用修改全局变量的值

	cout << "修改后静态变量的值: " << Func() << endl;
	return 0;
}

2. 返回全局变量

cpp 复制代码
/*--------------引用作为函数的返回值的注意事项--------------*/
#include <iostream>
using namespace std;

int a = 10; //全局变量

/*-----------使用引用作为函数的返回值-----------*/
int& Func()
{
	return a; //返回的是全局变量,全局变量生命周期持续到程序结束
}

int main()
{
	int& res = Func();
	cout << "访问返回的全局变量引用: " << res << endl;

	res = 20; //通过引用修改全局变量的值

	cout << "修改后全局变量的值: " << Func() << endl;
	return 0;
}

3. 返回传入的引用参数

cpp 复制代码
/*--------------引用作为函数的返回值的注意事项--------------*/
#include <iostream>
using namespace std;



/*-----------使用引用作为函数的返回值-----------*/
int& Func(int& num)
{
    num *= 2;    //修改传入的参数
    return num;  //返回该引用,通过参数传入的引用,其生命周期由调用方控制,必然长于函数执行时间。
}

int main() 
{
    int x = 5;
    cout << "原始值: " << x << endl;  // 输出: 5

    int& ref = Func(x);  //ref 是 x 的引用
    cout << "修改后的值: " << ref << endl;  // 输出: 10

    ref += 3;  //通过引用继续修改 x
    cout << "再次修改后的值: " << x << endl;  // 输出: 13

    return 0;
}

引用有哪些需要注意的事项?

1. 引用必须在声明时初始化

  • 引用必须在定义时绑定到一个变量(或对象)
cpp 复制代码
int x = 10;

int& ref = x; // ✔ 正确,引用必须初始化
int& ref2;    // ❌ 错误,引用未初始化 

2. 引用不能绑定到临时变量(除非是 const 引用)

  • 普通引用不能绑定到临时变量(如:字面量、表达式结果等)
  • const 引用可以绑定到临时变量(延长生命周期)
cpp 复制代码
int& r1 = 42;         // ❌ 错误,不能绑定到字面量
const int& r2 = 42;   // ✔ 正确,const 引用可以绑定临时变量

3. 引用不能为 NULL,但指针可以

  • 引用必须绑定到一个有效的对象,不能像指针那样指向 NULLnullptr
cpp 复制代码
int* ptr = nullptr;  // ✔ 指针可以为空
int& ref = nullptr;  // ❌ 错误,引用不能为空

4. 引用不能重新绑定

  • 一旦引用初始化后,它永远绑定到初始化的变量,不能更改。
cpp 复制代码
int a = 10, b = 20;
int& ref = a;  

ref = b;       //这是赋值,不是重新绑定(ref 仍然是 a 的引用,只是 a 的值变成 20)
int& ref2 = b; // ref2是新引用,不是重新绑定

5. 引用没有自己的地址

  • 引用只是别名,不占用额外内存,&ref 返回的是原变量的地址。
cpp 复制代码
int x = 10;
int& ref = x;
cout << &x << " " << &ref; // 输出相同的地址

总结

  • 引用必须在声明时初始化
  • 引用不能绑定到临时变量(除非是 const 引用)
  • 引用不能为 NULL(但指针可以)
  • 引用不能重新绑定
  • 引用没有自己的地址

引用和指针的区别是什么?

我想这个问题应该是大家一开始学习引用的时候,最想搞懂的问题了吧!

特性 引用 (int&) 指针 (int*)
初始化 必须初始化 可以不初始化
可修改绑定 不能重新绑定 可以指向不同对象
是否为别名 是(与原变量共用同一块内存空间) 否(自己有独立的内存)
是否可为 NULL 不能 可以
是否占用内存 不额外占用 占用指针大小
是否支持多级间接访问 不支持(不能int&& 支持(int**
适用场景 函数参数、函数返回值、别名 动态内存、可选参数、多级间接访问

总结:

引用 :是变量的别名,必须初始化且一旦绑定就不能再改变,对引用的操作等同于对其绑定变量的操作。

指针 :是存储变量地址的变量,它可以在运行时指向不同的变量,且可以为空指针。

---------------常量引用---------------

什么是常量引用?

常量引用(const引用) :过const修饰符声明,提供对对象的 只读访问 权限。

  • 它被用来 引用一个常量对象 ,或者 只读的方式引用一个普通对象,以确保在通过该引用访问对象时不会意外地修改对象的值。
  • 它绑定到一个对象后,不能通过该引用修改对象的值 ,但可以通过其他途径(:原变量名)修改。

常量引用怎么使用?

常量引用的定义方式与普通引用类似,只是在类型前面加上const关键字。

  • 例如const int& ref = num;,这里ref就是一个常量引用,它引用了变量num

常量引用和普通引用在绑定上的区别?

比较维度 普通引用 常量引用
绑定左值 可以绑定到与其类型匹配的const左值 例如int a; int& ra = a; ,这里ra是普通引用,绑定到非constint类型左值a 可以绑定到与其类型匹配的 const左值 ,也能绑定到 const左值 例如const int b = 10; const int& rb = b; ,常量引用rb绑定到const左值bint c = 20; const int& rc = c; ,常量引用rc绑定到非const左值c
绑定右值 不能 绑定到右值(如:字面量、表达式结果等临时值 ) 例如int& r1 = 5; 是不合法的,因为5是右值 可以绑定到右值 。编译器会创建一个临时对象存储右值,常量引用绑定到这个临时对象上 例如const int& r2 = 5; 是合法的
绑定不同类型 (存在隐式转换) 通常要求引用类型与被引用对象类型严格一致,一般不允许 绑定到不同类型的对象(即使存在隐式转换 ) 例如double d = 3.14; int& rd = d; 不合法,因为类型不一致 在存在隐式转换时,可以 绑定到不同类型的对象 例如double e = 3.14; const int& re = e; 是合法的,编译器会创建临时int对象,将e隐式转换后存储其中,再让常量引用re绑定到该临时对象

常量引用和普通引用在绑定上的区别(精简版):

对象类型 T&(普通引用) const T&(常量引用)
与其类型完全匹配非const左值变量 ✔️ ✔️
与其类型不匹配的非const左值变量 ✔️
const左值变量 ✔️
非const右值(临时值) ✔️
const右值 ✔️

口说无凭,下面博主一一进行测试!!!

说明:测试分为两部分:一部分针对的是普通引用 ,另部分是针对的常量引用的。

那么我们先来看看 普通引用 的绑定吧!

普通引用 :只能绑定到与其类型完全匹配的 非const左值

cpp 复制代码
/*--------------普通引用的绑定--------------*/
#include <iostream>
using namespace std;


int main()
{
	/*-------绑定到与其类型完全匹配的非const左值-------*/
	int num = 10;
	int& ref = num; // 正确

	cout <<"绑定到与其类型完全匹配的非const左值:" << ref << endl;
	return 0;
}
cpp 复制代码
/*--------------普通引用的绑定--------------*/
#include <iostream>
using namespace std;

int main()
{
    /*-------与其类型不匹配的非const左值变量-------*/
    double num = 3.14;  
    int& ref = num;  // 错误!类型不匹配,普通引用不能绑定

    cout << "与其类型不匹配的非const左值变量: " << ref << endl; 
    return 0;
}

绑定与其类型不匹配的非const左值变量错误原因:

  1. 普通引用(int& 要求与被引用对象的类型严格匹配 ,且必须绑定到同类型的非const左值
  2. 变量num的类型为double ,与引用类型int&不匹配,且不存在从doubleint&的隐式转换
  3. 若允许这种绑定 ,通过ref修改int值会导致内存操作不一致(例如:写入 4 字节int到 8 字节double内存区域),破坏数据完整性,因此 C++ 直接禁止此类绑定。
cpp 复制代码
/*--------------普通引用的绑定--------------*/
#include <iostream>
using namespace std;


int main()
{
	/*-------绑定到const左值变量-------*/
	const int num = 20;
	int& ref = num; // 错误

	cout << "绑定到const左值变量:" << ref << endl;
	return 0;
}

绑定到const左值变量的错误原因:

  1. 普通引用(int& 允许修改被引用的对象。
  2. const对象(如const int num 禁止被修改。
  3. 如果允许普通引用绑定const对象,就可以通过引用修改const对象,这违反了const的语义。
cpp 复制代码
/*--------------普通引用的绑定--------------*/
#include <iostream>
using namespace std;

//返回非const右值的函数
int getValue() 
{ 
    return 42; 
}

int main() 
{
    /*-------------------------普通引用绑定非const右值-------------------------*/

    /*----------------- 字面量场景 -----------------*/
    int& ref1 = 10;  // 编译错误:无法将右值绑定到左值引用
    cout << "普通引用绑定字面量非const右值" << ref1 << endl;


    /*----------------- 表达式场景 -----------------*/
    int a = 5, b = 3;
    int& ref2 = a + b;  // 编译错误:无法将右值绑定到左值引用
    cout << "普通引用绑定表达式非const右值" << ref2 << endl;



    /*----------------- 函数返回值场景 -----------------*/
    int& ref = getValue();  // 编译错误:无法将右值绑定到左值引用

    cout << "引用绑定函数返回值非const右值" << ref << endl;

    return 0;
}

绑定非const右值的错误原因:

  1. 普通引用(int& 必须绑定到左值(具有持久内存地址的对象)
  2. 右值(如字面量10、表达式a + b、函数返回的临时值) 是临时对象,没有持久内存地址,生命周期仅限于表达式求值期间。
  3. 若允许普通引用绑定右值 ,引用会在右值被销毁后悬空,导致后续对引用的访问(如:ref1ref2)触发未定义行为
cpp 复制代码
/*--------------普通引用的绑定--------------*/
#include <iostream>
using namespace std;

//返回const右值的函数
const int getConstValue() 
{ 
    return 99; 
}

int main()
{
    /*------------普通引用绑定const右值------------*/
     int& ref = getConstValue();  // 编译错误:无法将const右值绑定到非const左值引用

     cout << "引用绑定函数返回值const右值" << ref << endl;
}

绑定const右值的错误原因:

  1. 普通引用(int& 允许修改被引用的对象。
  2. const右值(如getConstValue()返回的const int 禁止被修改,其类型带有const限定。
  3. 若允许普通引用绑定const右值 ,则可通过引用(如:ref)修改原const对象,这违反了const的语义,导致类型系统不一致。
    看了上面这么多的例子,想毕大家已经有点晕晕的了吧!那么这里我们就先总结一下

第一类:绑定左值

  • 绑定到与其类型完全匹配的非const左值 ✔️
  • 绑定到与其类型不匹配的非const左值 ❌
  • 绑定到const左值变量 ❌

绑定到与其类型不匹配的非const左值 :会因内存布局不一致导致数据操作异常(如:通过int&修改double对象)

绑定const左值 :会使const对象可被修改,违反其不可变语义,这相当于"权限放大",C++禁止这种不安全操作

第二类:绑定右值

  • 绑定非const右值(临时值)❌
  • 绑定const右值 ❌

绑定非const右值 :会导致引用指向临时对象(无持久内存),产生悬空引用

绑定const右值:同时违反两条规则:

  1. 右值不可绑定到普通引用(生命周期问题)
  2. const对象不可通过非const引用访问(权限问题:权限放大不被允许)

接下来我们再看看 常量引用 的绑定

常量引用 :可以绑定到const左值const左值 以及右值

cpp 复制代码
/*--------------常量引用的绑定--------------*/
#include <iostream>
using namespace std;


int main()
{
	/*-------与其类型完全匹配的非const左值变量-------*/
	int num1 = 10;
	const int& ref1 = num1; // 正确

	cout <<"绑定到与其类型完全匹配的非const左值:" << ref1 << endl;

	/*-------与其类型不匹配的非const左值变量-------*/
	double num2 = 3.14;
	const int& ref2 = num2; // 正确

	cout << "绑定到与其类型不匹配的非const左值:" << ref2 << endl;
	return 0;
}
cpp 复制代码
/*--------------常量引用的绑定--------------*/
#include <iostream>
using namespace std;


int main()
{
	/*-------绑定const左值变量-------*/
	const int num = 10;
	const int& ref = num; // 正确

	cout << "绑定const左值变量:" << ref << endl;
	return 0;
}
cpp 复制代码
/*--------------常量引用的绑定--------------*/
#include <iostream>
using namespace std;

// 1. 非const右值返回函数
int getValue() { return 42; }

// 2. const右值返回函数
const int getConstValue() { return 99; }

/*---------------------------------常量引用绑定右值---------------------------------*/
int main() 
{
    /*----------------- 字面量场景 -----------------*/
    // 非const右值(字面量默认非const)
    const int& ref1 = 10;         // 合法:const&绑定字面量非const右值
    cout << "const&绑定字面量非const右值" << ref1 << endl;

    // const右值(显式const修饰的字面量)
    //注意:虽然字面量默认非const,但是我们可以显式const修饰的字面量,这里就不进行演示了,否则内容就超纲了
    //我们只需要记住:常量的引用无论是;"非const右值"还"const右值"都是可以进行绑定的


    /*----------------- 表达式场景 -----------------*/
    int a = 5, b = 3;

    // 非const右值(表达式结果默认非const)
    const int& ref2 = a + b;           // 合法:const&绑定表达式非const右值
    cout << "const&绑定表达式非const右值" << ref2 << endl;

    // const右值(表达式结果显式const修饰)
    //这里和字面场景是一样的           

    /*----------------- 函数返回值场景 -----------------*/
    // 非const右值(函数返回非const类型)
    const int& ref3 = getValue(); // 合法:const&绑定函数返回值非const右值
    cout << "const&绑定函数返回值非const右值" << ref3 << endl;

    // const右值(函数返回const类型)
    const int& ref4 = getConstValue();  // 合法:const&绑定函数返回值const右值
    cout << "const&绑定函数返回值const右值" << ref4 << endl;

    return 0;
}

接下来就赶紧总结一下不然一会又迷糊了!!

第一类:绑定左值

  • 绑定到与其类型完全匹配的非const左值 ✔️

    • 非const左值允许被修改,但const引用承诺不修改,这是"权限缩小"操作,C++允许这种安全转换
  • 绑定到与其类型不匹配的非const左值✔️

    • 常量引用会创建一个临时对象存储转换结果,引用绑定到临时对象而非原对象,避免类型不匹配风险,而C++规定临时对象具有常性,所以这里就触发了权限放大,必须要用常量引用才可以

      cpp 复制代码
      double pi = 3.14159;  //非const左值(类型double)
      
      //常量引用绑定到类型不匹配的对象(double→int)
      const int& ref = pi;  //创建临时对象:const int temp = static_cast<int>(pi);

临时对象: 所谓临时对象就是编译器需要一个空间暂存表达式的求值结果时临时创建的一个未命名的对象, C++中把这个未命名对象叫做临时对象。

  • 绑定到const左值变量 ✔️
    • const左值禁止被修改,普通引用(T&)无法保证不修改,而const T&严格匹配const属性,确保安全

第二类:绑定右值

  • 绑定非const右值(临时值)✔️
    • 右值(临时对象)生命周期短暂,但const引用会延长其生命周期至引用作用域结束,避免悬空引用
    • 例如const int& ref = 10; 临时量10的生命周期被延长,直到ref离开作用域。
  • 绑定const右值 ✔️
    • 生命周期延长const引用会将临时对象的生命周期延长至引用作用域结束,避免悬空引用。
    • 权限一致性 :双方均承诺不修改对象,符合const语义。

什么是"左值"和"右值"?

左值(lvalue)和 右值(rvalue):是表达式的两种基本属性。


左值(lvalue) :是一个表示内存中占有确定位置的对象的表达式。

  • 可出现在赋值语句的左侧
    • 例如:变量、数组元素、类成员等

特点

  • 有持久的内存地址 (可以用&取地址)
  • 可以被修改 (除非被声明为const

示例

cpp 复制代码
/*-------------------因为可以使用&取地址:所以是左值-------------------*/
int x = 10;      
int* ptr = &x;   // 可以取x的地址,说明x是左值


/*-------------------因为可以出现的赋值语句的左侧:所以是左值-------------------*/
x = 20;          // x可以出现在赋值语句左侧,是左值(变量)

int arr[5];
arr[0] = 1;      // arr[0]是左值(数组元素)

struct S 
{ 
    int a; 
};
S obj;
obj.a = 5;       // obj.a是左值(类成员)

右值(rvalue) :是一个表示内存中不占有确定位置的临时值的表达式。

  • 只能出现在赋值语句的右侧
    • 例如:字面量、表达式结果、函数返回的临时对象等

特点

  • 没有持久的内存地址 (不能用&取地址)
  • 不可以被修改

示例

cpp 复制代码
int x = 5;       // 5是右值(字面量)
int y = x + 3;   // x+3是右值(表达式结果)

// 错误:不能对右值取地址
// int* ptr = &(x + 3);  ❌

// 错误:右值不能出现在赋值语句左侧
// x + 3 = 10;           ❌
-------------------------------------------------------------------------------------
// 函数返回的临时值是右值
int getValue() 
{ 
    return 42; 
}
int z = getValue();  // getValue()的返回值是右值 (函数返回的临时对象)

总结对比一下左值和右值的区别:

特性 左值(lvalue) 右值(rvalue)
内存位置 有确定的存储地址 无持久存储地址(临时对象)
赋值操作 可出现在赋值左侧(可修改) 仅能出现在赋值右侧(不可直接修改
生命周期 生命周期超出当前表达式 通常在当前表达式结束后销毁
典型示例 变量、数组元素、解引用指针、非静态成员变量 字面量、临时对象、函数返回的非引用值
引用绑定 可绑定到 T&const T& 仅能绑定到 const T&T&&(右值引用)

常量引用的好处是什么?

通过上面的学习我们可以很明显地感觉到:常量引用(const T&)可以绑定多种数据类型,包括左值和右值(无论是否为const

因此:C++ 标准库函数的形参也是大量使用常量引用,这样做的目的主要有三点:


第一,提升函数通用性:常量引用允许函数接收左值和右值作为实参,减少了函数重载的必要性。

  • 例如std::string的构造函数std::string(const char* str)既能接收字符串字面量(右值),也能接收const char*类型的变量(左值),避免了为不同类型实参单独设计函数的冗余。

第二,提高函数性能:绑定右值时,常量引用通过延长临时对象的生命周期,避免了不必要的拷贝构造。

  • 例如std::vectorpush_back函数采用const T&接收参数,可直接复用传入对象,减少内存开销。

第三,提高函数安全性const限定防止函数内部意外修改实参,保证数据安全性。


综上所述:通过常量引用的灵活运用,C++ 标准库在保持类型安全的前提下,实现了高效、通用的接口设计,这也是现代 C++ 编程中广泛使用该特性的核心原因。

  • 例如std::sort函数的比较器参数bool(const T& a, const T& b),即使传入的T是复杂类对象,也能高效比较而无需复制整个对象,同时兼容临时对象和已有对象的比较场景。

---------------内联函数---------------

什么是内联函数?

内联函数(Inline Function):是 C++ 中一种通过编译器优化减少函数调用开销的机制。

思想

  • 在调用点直接展开函数体代码,而非通过传统的函数调用流程(如:压栈、跳转、返回等)

特点

  • 编译期展开:函数代码直接嵌入调用位置,无运行时调用开销。
  • 建议性:inline 仅是编译器提示,最终是否内联由编译器决定(如:函数体过大、递归等场景会忽略内联)
  • 头文件定义 :内联函数通常需要在头文件中直接完成定义,不建议将声明与定义分离到不同文件。
    • 这是因为内联函数在编译阶段会被直接展开到调用处,而非像普通函数那样生成独立的函数地址。
    • 如果将声明和定义分离,当多个源文件包含该头文件并调用内联函数时,由于每个源文件都展开函数体而没有统一的函数地址,链接器无法找到唯一的函数实现,从而导致链接错误。

怎么使用内联函数?

在函数定义前添加 inline 关键字即可:

cpp 复制代码
// 头文件中定义(推荐方式)
inline int add(int a, int b) 
{
    return a + b;
}

使用内联函数需要注意什么?

cpp 复制代码
/*-----------------------Inline.h-----------------------*/
#pragma once

#include <iostream>
using namespace std;

inline void f(int i);


/*-----------------------Inline.cpp-----------------------*/
#include "Inline.h"
void f(int i)
{
    cout << i << endl;
}


/*-----------------------Test.h-----------------------*/
#include "Inline.h"
int main()
{
    
    f(10);
    return 0;
}

相信大家经过上面的学习现在已经能明白上面的代码为什么会报错了,案例核心问题:内联函数的定义与声明分离

  • 内联函数 f 的声明在 Inline.h 中,但定义(实现)放在了 Inline.cpp 中。这违反了 C++ 内联函数的规则,导致链接器无法找到 f 的实体。

这里呢我们就详细的解析一下这个错误机制

  • 编译阶段
    每个源文件(如:Test.cppInline.cpp)独立编译。
    • Test.cpp 包含 Inline.h,但头文件中只有 f 的声明(无定义),因此编译器假设 f 的实现在其他文件中。
  • 链接阶段
    链接器尝试将 Test.cpp 中对 f 的调用与实际实现绑定,但 Inline.cpp 中定义的 f 由于缺少 inline 关键字,被视为普通函数的定义。此时:
    • Inline.cpp 未被编译(或未参与链接),链接器找不到 f 的实现,报错 "无法解析的外部符号"。
    • 即使 Inline.cpp 被编译,由于内联函数要求在每个调用点可见其完整定义,链接器仍会报错。

正确做法内联函数必须在头文件中完整定义

f 的定义直接放在 Inline.h 中,确保所有调用点都能看到完整实现。例如:

cpp 复制代码
// Inline.h
#pragma once
#include <iostream>
using namespace std;

inline void f(int i) // 直接在头文件中定义
{  
    cout << i << endl;
}

内联函数和宏函数的区别是什么?

相信小伙伴们看了上面关于 内联函数 的介绍后,感觉它和C语言中的 宏函数 在功能上好像啊,确实内联函数和宏函数都是:用于进行都是用于代码优化的技术。

并且我想偷偷的告诉你:内联函数被设计出来的目的就是为了取代宏函数 (此处缺少一个狗头)

所以接下来我们就先看看宏函数有哪些缺陷,最终导致了C++之父选择替换掉它的吧!!

cpp 复制代码
/*----------------------------宏函数的使用----------------------------*/
#include<iostream>
using namespace std;

/*--------------宏函数的常见错误实现示例--------------*/

// 1. 错误:宏不能包含"返回类型"和"return 语句"和";号"
//#define ADD(int a, int b) return a + b;  // 编译错误

// 2. 错误:缺少括号,可能导致运算符优先级问题
//#define ADD(a, b) a + b   // 例如:ADD(1, 2)*5 会被展开为 1 + 2*5 = 11(非预期的15)

// 3. 部分正确:加了内层括号,但外层仍可能有问题
//#define ADD(a, b) (a + b) // 例如:ADD(x & y, x | y) 会被展开为 (x & y + x | y),算数运算符"+"的优先级  高于 位运算符"&和|"的优先级


/*--------------正确的宏实现--------------*/

#define ADD(a, b) ((a) + (b))  // 内外均加括号,确保优先级和整体性
/*
* 下面的三个问题解答为什么上面的三种的宏函数的定义都是错误的
* 
* 为什么不能加分号?
*  - 宏是直接替换文本,加分号会导致语法错误。例如:
* int ret = ADD(1, 2);  -> int ret = ((1) + (2));;  // 多一个分号可能报错
* 
* 
* 为什么加外层括号?
* - 保证宏整体作为一个表达式。例如 ADD(1, 2)*5 会展开为 ((1) + (2))*5 = 15(符合预期)
* 
* 
* 为什么加内层括号?
* - 确保参数内部的运算符优先级。例如 ADD(x & y, x | y) 会展开为 ((x & y) + (x | y))
* 
*/
 

int main()
{
    //示例1:基本调用
    int ret = ADD(1, 2);  // 展开为 ((1) + (2)) -> 3

    //示例2:直接输出
    cout << ADD(1, 2) << endl;  // 展开为 ((1) + (2)) -> 3

    //示例3:参与表达式运算
    cout << ADD(1, 2) * 5 << endl;  // 展开为 ((1) + (2))*5 -> 15(若无外层括号会变成1+2*5=11)

    //示例4:测试参数含运算符的情况
    int x = 1, y = 2;
    cout << ADD(x & y, x | y) << endl;  // 展开为 ((x & y) + (x | y)) -> (0 + 3) = 3
    // 若无内层括号:x & y + x | y -> x & (y + x) | y(因+优先级高于&和|)

    return 0;
}

通过上面使用宏函数的代码案例,我们能够直观的感受到宏函数的使用非常的麻烦

最佳实践:优先使用内联函数保证类型安全和可维护性,仅在必要时使用宏

  • 例如:纯文本替换、预处理技巧,但是要避免宏的已知陷阱。
场景 内联函数
优先级问题 需手动加括号 自动处理
参数求值 可能多次求值(副作用) 只求值一次
类型检查
调试 难以调试(预处理器替换) 可调试
适用场景 简单代码片段、条件编译 小型函数

---------------nullptr---------------

什么是nullptr?

nullptr : 是 C++11 引入的 空指针字面量,用于表示不指向任何对象的指针。

  • 它是对传统 C 风格 NULL 的改进,旨在解决 NULL 在类型安全性上的缺陷。

为什么需要 nullptr?

哈哈,小伙伴们已经猜到了:那必然是C++祖师爷觉得C语言中的NULL不好,所以才引入了nullptr来替代NULL来了

话又说回来了,C语言的NULL哪里不好了呢?


传统 NULL 的问题

首先我们要先明确:C语言中的NULL通常被定义为 空指针(void*)0常量0 的宏定义(#define NULL 0),这会导致类型模糊。

  • 例如:函数重载时产生歧义,可能误匹配到整型参数而非指针
cpp 复制代码
void f(int x);       // 版本1:接受整数
void f(char* ptr);   // 版本2:接受指针

f(NULL);  // 调用哪个版本?多数编译器会选择版本1(整数重载)

但是这种行为违背直觉,因为 NULL 本意是表示空指针。


C++11nullptr 的解决

明确表示空指针,避免与整型混淆。

cpp 复制代码
#include<iostream>
using namespace std;

/*--------------------使用NULL调用重载函数产生歧义--------------------*/
// 重载函数 f,接受 int 类型参数
void f(int x)
{
    cout << "f(int x)" << endl;
}

// 重载函数 f,接受 int* 类型参数(指针)
void f(int* ptr)
{
    cout << "f(int* ptr)" << endl;
}

int main()
{
    f(0);  // 输出: f(int x)

    f(NULL);  // 输出: f(int x)(不符合预期)

    //强制转换 NULL 为 int* 类型,此时调用 f(int* ptr)
    f((int*)NULL);  // 输出: f(int* ptr)   

    // 编译错误:f 没有重载能接受 void* 类型参数
    // f((void*)NULL);  // error C2665: "f": 2个重载中没有一个可以转换所有参数类型


    // nullptr 是明确的空指针字面量,类型为 std::nullptr_t,可隐式转换为任何指针类型,因此会正确调用 f(int* ptr)
    f(nullptr);  // 输出: f(int* ptr)(符合预期)

    return 0;
}
特性 nullptr NULL0
类型 std::nullptr_t 整型(通常是 int
安全性 避免误匹配 可能误匹配整型重载
可读性 明确表示空指针 语义模糊
相关推荐
若水晴空初如梦21 分钟前
QT聊天项目DAY14
开发语言·qt
teeeeeeemo21 分钟前
Vue数据响应式原理解析
前端·javascript·vue.js·笔记·前端框架·vue
幼稚园的山代王25 分钟前
python3基础语法梳理(一)
开发语言·python
蒙奇D索大41 分钟前
【11408学习记录】考研数学攻坚:行列式本质、性质与计算全突破
笔记·学习·线性代数·考研·机器学习·改行学it
幼稚诠释青春1 小时前
面试实例题
java·开发语言
weixin_457665391 小时前
C++11新标准
开发语言·c++
阿蒙Amon1 小时前
C#封装HttpClient:HTTP请求处理最佳实践
开发语言·http·c#
moxiaoran57531 小时前
uni-app学习笔记二十四--showLoading和showModal的用法
笔记·学习·uni-app
tcoding1 小时前
《基于Apache Flink的流处理》笔记
笔记·flink·apache
大白爱琴1 小时前
使用python进行图像处理—像素级操作与图像算术(4)
开发语言·图像处理·python