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

张开发
2026/5/27 11:51:33 15 分钟阅读
【C++ 笔记】从 C 到 C++:核心过渡 (下)
前言在前面的讨论中我们已经全面分析了C语言与C在函数特性上的三大关键区别本文将详细解析 C 引用的知识点以及C语言NULL和C的nullptr历史之争一、引⽤在 C 中引用是一个非常重要且高效的概念。简单来说引用就是为一个已经存在的变量起了一个“别名”。1.1 什么是引用引用并非创建新变量而是为已有变量赋予别名。引用变量不会占用额外内存空间它与原变量共享同一内存地址。例如水浒传中李逵宋江称其为铁牛江湖人称黑旋风林冲则被唤作豹子头。1.2 引用的基本用法引用的声明方式是在类型名后加上符号。代码示例#includeiostream using namespace std; int main() { int a 10; int ref a; // ref 是 a 的引用 coutaaendl; //输出结果为a10 coutrefrefendl;//输出结果为ref10 return 0; }1.3 关键规则1.3.1 必须初始化引用在定义时必须立即指向一个变量不能像指针那样先定义再赋值。为什么必须初始化本质原因引用不是一个独立的对象。详细解释①在 C 中指针是一个实体它有自己的内存空间来存地址所以你可以先买个“盒子”声明指针以后再往里装地址。②但引用仅仅是一个名字别名如果你定义了一个引用却不初始化就相当于你告诉老师“我有个绰号叫‘小明’”但老师问你“ ‘小明’ 是谁的绰号”你却回答不上来。③在程序中没有目标的别名是没有意义的因此编译器会在编译阶段强制要求你指定目标。错误示例引用不进行初始化#includeiostream 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 不可变更性引用一旦指向某个变量就不能再改为指向另一个变量它一生只忠于一个变量。很多人尝试通过赋值来改变引用的指向但结果往往出乎意料。#includeiostream using namespace std; int main() { int a 10; int b 20; int ref a; ref b; // 猜猜看ref 现在指向 b 了吗 cout a a endl; //输出结果 a20 cout ref refendl; //输出结果 ref20 return 0; }真相 ref b 这一行代码并不是让 ref 变成 b 的引用而是把 b 的值赋值给了 ref 所指向的变量 a。执行后a 的值变成了 20ref 依然指向 a。1.3.3 不占独立空间逻辑上从逻辑上看引用和原变量共享同一块内存地址。逻辑层语法层面从程序员的角度看引用确实不占空间。如果你对引用取地址你会发现结果和原变量一模一样。#includeiostream 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引用来指向它。此时你不能通过这个引用修改变量但原变量依然可以通过它自己的名字修改。#includeiostream 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 的初衷。#includeiostream 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 aa 就是左值。②右值 指的是临时、没有名字、没有固定地址的值。它们通常是计算的中间结果或字面量。它们像“流星”一样用完就没了。例如数字 10表达式 a b 的结果代码示例一引用绑定常量#includeiostream 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 指向这块空间。这样既满足了引用的语法又保证了效率通过”代码示例二引用绑定临时表达式#includeiostream 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 一样长。一个极具迷惑性的对比#includeiostream using namespace std; int main() { int a 10; int b 5; // 情况 A int sum a b; const int rd1 sum; // rd1 绑定的是变量 sumsum 的地址是可见的 // 情况 B const int rd2 a b; // rd2 绑定的是编译器生成的“匿名临时变量” }在 情况 A 中如果你修改 sum 100rd1 也会变成 100。在 情况 B 中由于 a b 结果已经算完并存入匿名变量了哪怕你后面改了 a 0rd2 的值依然是 15因为它绑定的那个“匿名备份”不会变。代码示例三引用绑定类型转换时产生的临时变量#includeiostream 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到底引用了谁。#includeiostream 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::vectorint data) { // 加上 const 是为了防止函数内部误改数据 cout data.size() endl; }1.5.2 做函数的返回值支持连续操作引用作为返回值可以让函数调用像变量一样出现在赋值符号的左边作为左值。#includeiostream 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是 C11 引入的关键字它有一种特殊的类型叫 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 是纯粹的指针语义。它不能被赋值给普通的整型变量布尔值除外防止了逻辑混淆。代码示例#includeiostream 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_ptrptr; char* c_ptrptr; 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#includeiostream using namespace std; int main() { // 假设 NULL 是 (void*)0 int* p NULL; return 0; }编译器会立刻报错Error: cannot convert from void* to int* without a cast.不能将void* 的指针转换为 int*的指针这就意味着如果你想把指针置空你必须每次都这样写#includeiostream 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 可以特殊处理允许它隐式转换为任何指针类型。”于是在 C98 时代NULL被简单粗暴地定义为整数0。实际的 C 定义#define NULL 0这就解决了把指针置空麻烦的问题#includeiostream 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。既然看到这里了不妨关注点赞收藏感谢大家若有问题请指正。

更多文章