【C++ 笔记】从 C 到 C++:核心过渡 (下)

2025博客之星年度评选已开启 10w+人浏览 1.5k人参与

前言:

       在前面的讨论中,我们已经全面分析了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)严禁为空

①引用不需要检查空值(理论上),这让代码更简洁。

        

②但指针需要满地写 if(p)

访问方式手动挡 (*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

        

        

既然看到这里了,不妨关注+点赞+收藏,感谢大家,若有问题请指正。

                                                                     

评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值