前言:
在前面的讨论中,我们已经全面分析了C语言与C++在函数特性上的三大关键区别:
①缺省参数:必须从右向左依次赋值,声明与定义分离时需保持一致;
②函数重载:通过参数列表的类型或数量区分同名函数,注意避免调用时的二义性;
③内联函数:理解其实现原理和使用规范,注意与宏定义的区别,通常应将定义放在头文件中。
本文将详细解析 C++引用的知识点,以及C语言NULL和C++的nullptr历史之争

一、引⽤
在 C++ 中,引用是一个非常重要且高效的概念。简单来说,引用就是为一个已经存在的变量起了一个“别名”。
1.1 什么是引用
引用并非创建新变量,而是为已有变量赋予别名。引用变量不会占用额外内存空间,它与原变量共享同一内存地址。
例如:水浒传中李逵,宋江称其为"铁牛",江湖人称"黑旋风";林冲则被唤作"豹子头"。
1.2 引用的基本用法
引用的声明方式是在类型名后加上 & 符号。
代码示例:
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int& ref = a; // ref 是 a 的引用
cout<<"a="<<a<<endl; //输出结果为:a=10
cout<<"ref="<<ref<<endl;//输出结果为:ref=10
return 0;
}
1.3 关键规则
1.3.1 必须初始化
引用在定义时必须立即指向一个变量,不能像指针那样先定义再赋值。
为什么必须初始化?
本质原因:引用不是一个独立的对象。
详细解释:
①在 C++ 中,指针是一个实体(它有自己的内存空间来存地址),所以你可以先买个“盒子”(声明指针),以后再往里装地址。
②但引用仅仅是一个名字(别名),如果你定义了一个引用却不初始化,就相当于你告诉老师:“我有个绰号叫‘小明’”,但老师问你:“ ‘小明’ 是谁的绰号?”你却回答不上来。
③在程序中,没有目标的别名是没有意义的,因此编译器会在编译阶段强制要求你指定目标。
错误示例:引用不进行初始化
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int& ref = a; // 正确:ref 是 a 的别名
int& ref2; // 错误!编译器会报错:'ref2' declared as reference but not initialized
return 0;
}
1.3.2 不可变更性
引用一旦指向某个变量,就不能再改为指向另一个变量(它一生只忠于一个变量)。
很多人尝试通过赋值来改变引用的指向,但结果往往出乎意料。
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int b = 20;
int& ref = a;
ref = b; // 猜猜看:ref 现在指向 b 了吗?
cout << "a =" << a << endl; //输出结果 a=20
cout << "ref ="<<ref<<endl; //输出结果 ref=20
return 0;
}
真相: ref = b 这一行代码并不是让 ref 变成 b 的引用,而是把 b 的值赋值给了 ref 所指向的变量 a。
执行后:a 的值变成了 20,ref 依然指向 a。
1.3.3 不占独立空间(逻辑上)
从逻辑上看,引用和原变量共享同一块内存地址。
逻辑层(语法层面):从程序员的角度看,引用确实不占空间。如果你对引用取地址,你会发现结果和原变量一模一样。
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int& ref = a;
cout << &a << endl; // 输出 0x7ffee...
cout << &ref << endl; // 输出 0x7ffee...(地址完全相同)
return 0;
}
由此可知,在语言的标准定义里,引用就是变量的另一个名字,它们共用同一块内存区域。
我们在学习语法规则的时候姑且不谈论引用的底层实现,即不考虑物理层面。
1.4 引用权限(重点)
在 C++ 中,引用的“权限问题”主要围绕 const 限定符展开。这决定了你是否可以通过引用修改原变量,以及哪些变量可以被引用。
我们可以将引用的权限规则总结为:“权限可以缩小,但不能放大。”
1.4.1 权限缩小:非 const 变量可以被 const 引用
如果你有一个可以修改的变量,你可以定义一个 const 引用来指向它。此时,你不能通过这个引用修改变量,但原变量依然可以通过它自己的名字修改。
#include<iostream>
using namespace std;
int main()
{
int a = 10;
const int& ref = a; // 权限缩小:a 可读写,但 ref 只能读
// ref = 20; // 错误!不能通过ref别名进行改变a,因为其被const引用修改
a = 20; // 正确!原变量依然有写权限
return 0;
}
1.4.2 权限不能放大:const 变量不能被非 const 引用
如果一个变量本身是 const(只读),你绝对不能定义一个普通引用(读写)来指向它。否则就相当于通过“后门”获得了修改权限,这违背了 const 的初衷。
#include<iostream>
using namespace std;
int main()
{
const int a = 10;
int &ref = a; // 错误!放大了权限 编译失败:不能将 const 映射为非 const
const int& ref = a; // 正确:必须也是 const 引用
return 0;
}
1.4.3 权限与临时变量(左值与右值)
这是 C++ 权限规则中比较“玄学”但极其重要的一点:普通引用不能绑定临时变量,但常量引用可以。
简介左值与右值:
①左值:指的是有名字、有固定内存地址的对象,你可以把它看作一个“容器”。因为它在内存里稳稳地呆着,所以你可以多次使用它。
例如:变量 int a,a 就是左值。
②右值 :指的是临时、没有名字、没有固定地址的值。它们通常是计算的中间结果或字面量。它们像“流星”一样,用完就没了。
例如:数字 10,表达式 a + b 的结果
代码示例一:引用绑定常量
#include<iostream>
using namespace std;
int main()
{
//错误写法:
int &ref = 10; // 绝对错误!
//正确写法:
const int& ref = 10; // 正确!编译器会为 10 创建一个临时内存空间
return 0;
}
为什么 int &ref = 10; 绝对错误?
编译器的吐槽:
①“程序员告诉我 ref 是 10 的别名,但 10 只是一个硬编码在指令里的数字(立即数),它根本没有内存地址!
② 如果我让 ref 绑定成功了,万一程序员后面写一句 ref = 20; 难道我要去修改 CPU 指令里的数字吗?这太荒谬了,报错!
为什么 const int& ref =10 ; 又是正确的?
编译器的通融:
①“程序员说 ref 是 10 的别名,但加了 const 保证只读不写。
②既然不写,那就好办了。我偷偷在栈上开一块 4 字节的空间,把 10 填进去,然后让 ref 指向这块空间。
这样既满足了引用的语法,又保证了效率,通过!”
代码示例二:引用绑定临时表达式
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int b = 5;
int& rd = a + b; //绝对错误
const int& rd = a + b; //正确写法
return 0;
}
为什么 int& rd = a + b; 绝对错误?
①在执行 a + b 时,CPU 计算出结果 15,并将其存放在一个临时寄存器或栈上的临时空间里。
②没有持久地址:这个 15 在 C++ 中被称为 “右值(PRvalue)”。它就像划过天空的流星,在这一行代码结束后就会被立即销毁。
③修改无意义:如果你通过 rd 修改了这个 15(比如 rd = 20),你修改的只是一个即将消失的临时内存,而 a 和 b 本身的值并不会改变。
④编译器拦截:为了防止这种逻辑混乱,编译器强制规定,非常量左值引用不能绑定到右值上。
为什么 const int& rd = a + b; 是正确的?
当你加上 const 后,性质发生了翻天覆地的变化,编译器的幕后操作(三部曲):
①开辟空间:编译器在栈上偷偷开辟了一个隐藏的匿名变量,比如 int temp = a + b;。
②绑定引用:让 rd 成为这个 temp 的别名。
③续命(核心):原本 a + b 的结果在这一行执行完就该消失了,但因为被 rd(常量引用)引用了
温馨提示:编译器规定,这个临时变量的生命周期将延长到与引用 rd 一样长。
一个极具迷惑性的对比
#include<iostream>
using namespace std;
int main()
{
int a = 10;
int b = 5;
// 情况 A
int sum = a + b;
const int& rd1 = sum; // rd1 绑定的是变量 sum,sum 的地址是可见的
// 情况 B
const int& rd2 = a + b; // rd2 绑定的是编译器生成的“匿名临时变量”
}
在 情况 A 中,如果你修改 sum = 100,rd1 也会变成 100。
在 情况 B 中,由于 a + b 结果已经算完并存入匿名变量了,哪怕你后面改了 a = 0,rd2 的值依然是 15,因为它绑定的那个“匿名备份”不会变。
代码示例三:引用绑定类型转换时产生的临时变量
#include<iostream>
using namespace std;
int main()
{
double d = 12.34;
int& rd = d; // 错误
const int& rd = d; // 正确
}
这里很多人会奇怪:d 明明是一个有内存地址的变量(左值),为什么 int& 还是不行?
真相:类型不匹配时会产生“临时变量”。
①rd 想要引用的是一个 int,但 d 是 double。
②为了让赋值成立,编译器必须先把 d 转换成 int,转换的过程类似于:int temp = (int)d;。
③此时,rd 引用的其实是那个临时生成的 temp,而不是原来的 d!
温馨提示:
①其中临时变量的生命周期与引用的变量一致,当rd销毁时,临时变量tmp才会销毁。
②如果编译器允许 int& rd = d; 成立,你随后执行 rd = 100; 此时改变的是那个临时变量 temp,而原本的 double d 依然是 12.34。
这种“指东打西”的行为会导致极其严重的 Bug,因此 C++ 规定:除非是常量引用,否则禁止绑定到因类型转换产生的临时变量上。
代码实验验证:你可以尝试在代码中打印地址,观察 const int& rd 到底引用了谁。
#include<iostream>
using namespace std;
int main()
{
double d = 12.34;
const int& rd = d;
cout << "Address of d: " << &d << endl;
cout << "Address of rd: " << &rd << endl; // 你会发现这两个地址竟然不一样!
}
这个实验可以铁证:rd 此时确实是在引用一个编译器偷偷创建的临时 int 变量,而不是直接引用 d。
1.5 引用的应用场景
谈及了应用的诸多注意事项和使用条件,那么引用到底可以在哪些场景中去进行使用呢?
引用的核心价值在于“高效”和“直观”。在 C++ 中,引用主要活跃在函数参数、返回值以及复杂的对象访问中。
1.5.1 做函数参数:提高效率与修改原值
①避免拷贝(性能优化): 如果传递一个巨大的对象(比如一个包含 100 万个元素的 vector 或一张高分辨率图片),传统的“传值”会把整个对象复制一遍,极其耗时耗内存,然而通过使用引用只需要传递一个地址(逻辑上)。
②实现“输出型”参数: 当一个函数需要改变外部变量的值时(例如交换两个数),必须使用引用。
// 场景一:交换数值(必须改原值)
void swap(int& left, int& right)
{
int temp = left;
left = right;
right = temp;
}
// 场景二:大数据传输(避免拷贝)
void processBigData(const std::vector<int>& data)
{
// 加上 const 是为了防止函数内部误改数据
cout << data.size() << endl;
}
1.5.2 做函数的返回值:支持连续操作
引用作为返回值,可以让函数调用像变量一样出现在赋值符号的左边(作为左值)。
#include<iostream>
using namespace std;
int& getElement(int arr[], int index)
{
return arr[index]; // 返回数组元素的引用
}
int main()
{
int myArr[5] = { 0 };
getElement(myArr, 0) = 100; // 函数调用放在左边,直接修改数组内容
// 等同于 myArr[0] = 100;
}
1.5.3 引用使用的注意事项
将引用作为函数的返回值,当函数执行完毕后,它的栈帧(Stack Frame)会被销毁,函数内部定义的局部变量也随之烟消云散。
如果你返回了它的引用,外部程序拿到的就是一个指向“死区”的地址。
让我们看一个会产生“野引用”的代码:
#include <iostream>
using namespace std;
int& getLocalValue()
{
int temp = 100; // 局部变量,存储在栈区
return temp; // 危险!返回局部变量的引用
}
int main()
{
int& res = getLocalValue();
// 此时 getLocalValue 的栈空间已经回收
// res 变成了一个指向非法地址的“野引用”
cout << res << endl; // 结果不确定:可能输出 100,也可能是随机数或导致崩溃
return 0;
}
1.6 指针与引用的关系
指针与引用是 C++ 中一对 “冤家”,理解二者关系需抛开语法糖,从语法设计 和 底层实现 两个维度分析。
我们可以用一句话概括它们的关系:引用在底层本质上就是指针,但在语言层面上,它是受了严格限制且语法更优雅的指针。
1.6.1 底层实现
在编译器(如 GCC, MSVC)生成的汇编代码中,引用和指针通常是一模一样的。
指针的写法:
int a = 10;
int* const p = &a; // const修饰保证了指针 p 的指向不能变
*p = 20; // 修改值需要解引用 (*)
引用的写法:
int a = 10;
int& r = a; // 编译器在底层默默把它处理成上面的指针
r = 20; // 编译器自动帮你做了解引用
1.6.2 语法设计
虽然底层像,但 C++ 标准为了让代码更安全、更易读,给“引用”套上了层层枷锁,把它包装成了指针的安全版。
| 维度 | 指针 (Pointer) | 引用 (Reference) |
关系解读 |
| 存在感 | 独立个体 | 依附者 |
①指针是一个实体变量,存着地址;
②引用只是原变量的别名。 |
| 可变性 | 花心 | 专一 |
①指针可以随时指向别人;
②引用一辈子只能跟定初始化时的那个变量。 |
| 空值 | 允许为空 (nullptr) | 严禁为空 |
①引用不需要检查空值(理论上),这让代码更简洁。
②但指针需要满地写 |
| 访问方式 | 手动挡 (*p, p->) | 自动挡 (r.) | 引用由编译器自动处理寻址,写起来像普通变量。 |
| 多级 | 有多级指针 (int**) | 无多级引用 | int&& 是右值引用,不是“引用的引用”。引用只有一级。 |
二、nullptr与NULL
在 C++ 开发中,nullptr 和 NULL 虽然都代表“空指针”,但它们有着本质的区别。
简单来说,NULL 是过去留下的历史包袱(本质是整数 0),而 nullptr 是为了修复这些问题而诞生的现代方案(真正的指针类型)。
2.1 本质区别:类型之争
NULL:在 C++ 标准库头文件中,它通常被定义为宏:#define NULL 0 ,也就是说,它的本质是一个整数(int)。
nullptr:是 C++11 引入的关键字,它有一种特殊的类型叫 std::nullptr_t,它可以隐式转换为任意类型的指针,但不能转换为非零的整数。
2.2 为什么 NULL 会导致问题
由于 NULL 的本质还是 0,编译器在处理函数重载时会产生严重的歧义,这往往会导致意想不到的 Bug
请看下面这个经典的例子:
#include <iostream>
using namespace std;
void func(int x)
{
cout << "调用了 func(int) -> 处理整数逻辑" << endl;
}
void func(int* ptr)
{
cout << "调用了 func(int*) -> 处理指针逻辑" << endl;
}
int main()
{
// 你的意图:我想传一个空指针进去
//如果进行传入NULL
func(NULL);
// 实际结果:调用了 func(int)!
// 原因:NULL 被替换成了 0,编译器认为 0 是整数,所以优先匹配 int 参数。
func(nullptr);
// 实际结果:调用了 func(int*)
// 原因:nullptr 是指针类型,它只能匹配指针版本的函数。
return 0;
}
解析:当你本想传递一个“空地址”时,使用 NULL 可能会误触整数版本的函数,这种隐蔽的错误在大型项目中极难排查。
2.3 nullptr 的核心优势
1. 类型安全:nullptr 是纯粹的指针语义。它不能被赋值给普通的整型变量(布尔值除外),防止了逻辑混淆。
代码示例:
#include<iostream>
using namespace std;
int main()
{
int i = nullptr; // 编译报错:无法将 nullptr 转为 int
int* ptr = nullptr; //正确
return 0;
}
2. 消除歧义:如下例所示,在函数重载和模板推导中,它能准确匹配指针类型。
#include <iostream>
using namespace std;
void func(int x)
{
cout << "调用了 func(int) -> 处理整数逻辑" << endl;
}
void func(int* ptr)
{
cout << "调用了 func(int*) -> 处理指针逻辑" << endl;
}
int main()
{
// 你的意图:我想传一个空指针进去
//如果进行传入NULL
func(NULL);
// 实际结果:调用了 func(int)!
// 原因:NULL 被替换成了 0,编译器认为 0 是整数,所以优先匹配 int 参数。
func(nullptr);
// 实际结果:调用了 func(int*)
// 原因:nullptr 是指针类型,它只能匹配指针版本的函数。
return 0;
}
3.代码可读性:看到 nullptr,阅读者能立刻知道这里是在操作内存地址,而不是数学上的 0。
2.4 为什么C++不能用 (void*)0
你可能会问,C 语言里的 NULL 往往定义为 (void*)0,为什么在 C++中不继续沿用这一点呢?
2.4.1 C 语言的“万能钥匙” (void*)
在 C 语言中,void* 被设计成一个通用指针,相当于一张“万能通行证”。
C 语言规则:void* 可以隐式转换(自动转换)为任何其他类型的指针,不需要强制转换。
代码示例:最常见的 malloc(内存分配)
这是 C 语言中最广泛的应用场景,标准库函数 malloc 返回的就是 void*,因为它只负责切一块内存给你,并不关心你拿这块内存存整数还是存字符串。
#include <stdlib.h>
int main()
{
// malloc 返回 void*
// 左边是 int*
// C 语言编译器:自动将 void* 转为 int*,无需人工干预
int* p = malloc(sizeof(int) * 10);
return 0;
}
代码示例:void* 可以隐式转换为任意类型的指针
#include <stdio.h>
int main()
{
int a = 10;
// 1. 任何指针都能转为 void* (C 和 C++ 都允许)
void* ptr = &a;
// 2. void* 转回具体类型 (C 允许隐式,C++ 禁止)
// 这里 ptr 是 void*,赋值给 float*
// C 编译器直接放行,虽然逻辑上可能有风险(int转float解释),但语法合法
float* f_ptr = ptr;
double* d_ptr=ptr;
char* c_ptr=ptr;
return 0;
}
2.4.2 C++ 的“严格安检”
C++ 引入了面向对象(类、继承等)概念,为了防止内存错乱,大大加强了类型安全检查。
C++ 规则:void* 表示“无类型指针”,也就是说你可以把任意指针赋给 void*,但反过来不行!
如果将 void* 赋给具体指针(如 int*)则必须要进行显式强制转换。
你可能会问,为什么 C++ 禁止 void* 隐式转 int* ?
C++ 认为:
void*指向的内存不知道是什么结构,如果允许随便转成int*或Car*,万一转换错了,程序在运行时就会崩溃。
所以编译器要求程序员:“如果你非要转,你自己签字画押(写强制转换代码),后果自负。”
2.4.3 C++ 沿用 (void*)0 会发生什么
假设 C++ 中 NULL 依然定义为 (void*)0:
#include<iostream>
using namespace std;
int main()
{
// 假设 NULL 是 (void*)0
int* p = NULL;
return 0;
}
编译器会立刻报错,Error: cannot convert from 'void*' to 'int*' without a cast.
这就意味着,如果你想把指针置空,你必须每次都这样写:
#include<iostream>
using namespace std;
int main()
{
int* p = (int*)NULL; // 麻烦!
char* s = (char*)NULL; // 烦死人!
MyClass* obj = (MyClass*)NULL; // 代码简直没法看了!
return 0;
}
2.4.4 尴尬的妥协(NULL = 0)
为了让程序员写代码时不那么痛苦,C++ 标准委员会不得不做出妥协:
“既然 (void*)0 无论如何都通不过编译,那我们只好规定:整数常量 0 可以特殊处理,允许它隐式转换为任何指针类型。”
于是,在 C++98 时代,NULL 被简单粗暴地定义为整数 0。
实际的 C++ 定义:
#define NULL 0
这就解决了把指针置空麻烦的问题:
#include<iostream>
using namespace std;
int main()
{
int* p = 0; // 合法,C++ 特许 0 转指针
int* q = NULL; // 合法,因为 NULL 宏展开就是 0
int* ptr = 1; //严禁
return 0;
}
2.5 总结NULL与nullptr
在C++中,通过 #define NULL 0 而不是(void*)0 ,但是为了方便指针置为悬空,C++规定 NULL 或 0 可以隐式转换为任意类型。
并且通过引入关键字nullptr ,来解决在函数重载时,NULL会错误地匹配到 int 参数,而不是指针参数的问题。
结论:现代 C++ 请彻底遗忘 NULL,全员使用 nullptr。
既然看到这里了,不妨关注+点赞+收藏,感谢大家,若有问题请指正。


412

被折叠的 条评论
为什么被折叠?



