本篇文章从C++的起源与发展入手,主要讲解C++中的一些基础的语法及一些关键字,为之后学习类和对象打下一个坚实的基础。
C++的起源最早可追溯至1979年,当时本贾尼博士在贝尔实验室从事计算机科学和软件工程的研究工作,面对项目中复杂的软件开 发任务,特别是模拟和操作系统的开发工作,他感受到了现有语言(如C语言)在表达能力、可维护性和可扩展性方面的不足。
1983年,BjarneStroustrup在C语言的基础上添加了⾯向对象编程的特性,设计出了C++语言的雏形,此时的C++已经有了类、封装、继承等核⼼概念,为后来的⾯向对象编程奠定了基础。这一年该语言被正式命名为C++。
在随后的几年中,C++在学术界和工业界的应用逐渐增多。一些大学和研究所开始将C++作为教学和研究的首选语言,而已些公司也开始在产品开发中尝试使用C++。这一时期,C++的标准库和模板等特性也得到了进一步的完善和发展。
C++的标准化工作于1989年开始,并成立了⼀个ANSI和ISO(International Standards Organization)国际标准化组织的联合标准化委员会。1994年标准化委员会提出了第⼀个标准化草案。在该草案中,委员会在保持斯特劳斯特卢普最初定义的所有特征的同时,还增加了部分新特征。
在完成C++标准化的第一个草案后不久,STL(Standard Template Library)是惠普实验室开发的一系列软件的统称。它是由Alexander Stepanov、MengLee和David RMusser在惠普实验室⼯作时所开发 出来的。在通过了标准化第一个草案之后,联合标准化委员会投票并通过了将STL包含到C++标准中的 提议。STL对C++的扩展超出C++的最初定义范围。虽然在标准中增加STL是个很重要的决定,但也因 此延缓了C++标准化的进程。
1997年11月14日,联合标准化委员会通过了该标准的最终草案。1998年,C++的ANSI/IS0标准被投入使用。C++中的很多语法都是为例解决C语言中的不足
// test.cpp
#include<iostream>
using namespace std
int main()
{
cout<< "Hello world" <<endl;
return 0;
}
看到这个程序,我们肯定对其有所不解,头文件为什么不加 .h 后缀,下面的 using 又是什么,这些都会在接下来的文章中进行讲解。
在我们之前学习的C语言中,变量和函数等都是在全局作用域或局部作用域中存在的,而随着代码量的不断增大,我们会创建大量的变量和函数,这时如果都存放在全局或main函数中,很容易产生命名冲突,命名空间(namespace)的出现就是为了解决这个问题
1. 避免命名冲突(最重要的原因):
Add()
的函数,很有可能另一个同事也写了一个同名的函数。calculate()
。my_project.math
命名空间下,同事的函数放在 his_project.utils
命名空间下,调用时就变成了 my_project.math.calculate()
和 his_project.utils.calculate()
,冲突就解决了。2. 组织代码,提高可读性:
std::cout
时,你立刻就知道这是 C++ 标准库(std
是 standard 的缩写)里的输出功能。numpy.array
时,你知道这是 NumPy 库提供的数组类型。#include<stdio.h>
#include<stdlib.h>
int rand = 0; // err
//编译器查找确认的默认规则是,先局部查找,找不到在去全局查找
//因为,在头文件stdlib.h中包含了rand 函数,这时我们创建一个全局变量rand就会出现命名冲突
int main()
{
printf("%p\n", rand);
return 0;
}
#include<iostream>
using namespace std
// 1. 正常命名空间的定义
namespace A
{
// 命名空间中可以定义变量/函数/类型
int rand = 10;
int Add(int left, int right)
{
return left + right;
}
struct Node
{
struct Node* next;
int val;
};
}
int a = 0;
int main()
{
int a = 1;
// :: 叫域限定作用符,:: 左边不写默认全局
printf("%d\n",::a);
printf("%d\n",a);
// 这⾥默认是访问的是全局的rand函数指针
printf("%p\n", rand);
// 这⾥指定bit命名空间中的rand
printf("%d\n", A::rand);
// 调用命名空间A中的Add函数
printf("%d\n",A::Add(1,2));
return 0;
}
// 2. 命名空间的嵌套调用
namespace Student
{
namespace A
{
int age = 20;
int Add(int left, int right)
{
return left + right;
}
}
namespace D
{
int age = 18;
int number = 123;
}
}
int a = 1;
int main()
{
// 编译器默认查找规则不会再命名空间内查找,先在局部查找,在全局,找不到就会报错
int a = 0;
printf("%d ", a);
printf("%d ", Student::A::Add(1,2));
printf("%d ", Student::D::number);
return 0;
}
// Stack.h
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
namespace ST
{
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
void Init(ST* ps, int n);
void Destroy(ST* ps);
void Push(ST* ps, STDataType x);
void Pop(ST* ps);
}
// Stack.cpp
#include"Stack.h"
namespace ST
{
void Init(ST* ps, int n)
{
assert(ps);
ps->a = (STDataType*)malloc(n * sizeof(STDataType));
ps->top = 0;
ps->capacity = n;
}
// 入栈-栈顶
void Push(ST* ps, STDataType x)
{
assert(ps);
// 栈满,扩容
if (ps->top == ps->capacity)
{
printf("\n");
int newcapacity = ps->capacity == 0 ? 4 : ps->capacity* 2;
STDataType* tmp = (STDataType*)realloc(ps->a,
newcapacity * sizeof(STDataType));
if (tmp == NULL)
{
perror("realloc fail");
return;
}
ps->a = tmp;
ps->capacity = newcapacity;
}
ps->a[ps->top] = x;
ps->top++;
}
}
// Queue.h
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
namespace Queue
{
typedef int QDataType;
typedef struct QueueNode
{
int val;
struct QueueNode* next;
}QNode;
typedef struct Queue
{
QNode* phead;
QNode* ptail;
int size;
}Queue;
void Init(Queue* pq);
void Destroy(Queue* pq);
// ⼊队列
void Push(Queue* pq, QDataType x);
// 出队列
void Pop(Queue* pq);
}
// Queue.cpp
#include"Queue.h"
namespace Queue
{
//初始化
void Init(Queue* pq)
{
assert(pq);
pq->phead = NULL;
pq->ptail = NULL;
}
//入队——队尾入
void Push(Queue* pq, QDataType x)
{
assert(pq);
//创建值为x的节点
QueueNode* newnode = (QueueNode*)malloc(sizeof(QueueNode));
if (newnode == NULL)
{
perror("malloc fail!");
exit(1);
}
newnode->val = x;
newnode->next = NULL;
//队列为空
if (pq->phead == NULL)
{
pq->phead = pq->ptail = newnode;
}
else {
pq->ptail->next = newnode;
pq->ptail = pq->ptail->next;
}
}
}
// test.cpp
#include"Stack.h"
#include"Queue.h"
int main()
{
ST::ST st;
// 在Stack.h和Queue.h中,定义了同名函数Init,Push等,但是它们在不同的命名空间,所以调用时不会产生冲突
ST::Init(&st);
ST::Push(&st,1);
ST::Push(&st,2);
ST::Push(&st,1);
Queue::Queue q;
Queue::Init(&q);
Queue::push(&q,1);
Queue::push(&q,2);
Queue::push(&q,3);
return 0;
}
编译查找一个变量的声明/定义时,默认只会在局部或者全局查找,不会到命名空间里面去查找。
所以我们要使用命名空间中定义的变量/函数,有三种方式:
#include<stdio.h>
namespace student
{
int age;
}
// 编译查找时,编译器会先在局部查找,再在全局查找,不会在命名空间内查找
int main()
{
// 编译报错:未声明的标识符
printf("%d\n", age);
return 0;
}
// 指定命名空间访问
#include<iostream>
namespace student
{
int age;
int number;
}
using student::age; // 指定将命名空间中的某个成员展开
// 切记不要定义与展开的成员同名的变量
int age=0; // err
int main()
{
printf("%d\n", a);
// C++标准库都放在一个叫std的命名空间中,因此我们想要调用库中成员时,也要将要使用的成员展开
std::cout<< age <<<<endl;
return 0;
}
// 将命名空间中的所有成员展开
#include<iostream>
#include<algorithm>
usinig namespace std; // 将std命名空间中的所有成员展开,日常练习可以,项目不要这样
// C++标准库都放在一个叫std的命名空间中,因此我们想要调用库中的函数时,要先将他展开
int main()
{
int i = 0;
double b = 1.2;
//先从局部查找,在从全局查找,如果都没找到,再从展开的std中查找
cout << i << " " << b << std::endl;
int arr[] = { 2,5,2,7 };
sort(arr, arr + 3); // 库中的排序函数
return 0;
}
#include<iostream>
int main()
{
int i = 0;
i << 5; // 左移操作符
double b = 1.2;
// vs编译器中,iostream头文件间接包含了stdio.h,其他编译器可能会报错
scanf("%d,%lf", &a, &b);
printf("%d %lf\n", a, b);
//自动识别类型
std::cout << i<<'\n'<<"\n"; // 这两个用来换行都可以,不会刷新缓冲区
std::cout << "hello world"<<std::endl; // end line
std::cout << i << " " << b << std::endl; // 用这个换行会刷新缓冲区
//自动识别类型
std::cin >> i >> b;
std::cout << i << " " << b << std::endl;
//类,复杂类型,IO流支持复杂类型的输入输出
return 0;
}
int main()
{
// 在io需求比较高的地方,如部分大量输入的的竞赛中,加上以下三行代码
// 可以提高C++ IO效率
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
return 0;
}
// 全缺省参数
int func1(int x = 1, int y = 2)
{
return x + y;
}
// 半缺省参数
// 给缺省值时,必须从右向左依次给,不能跳跃
int func2(int x, int y = 2, int z = 3)
{
return x + y + z;
}
int main()
{
// 带有全缺省参数的函数调用时,不给实参则使用缺省值
int a = func1(); // 3
// 带有全缺省参数的函数调用时,从左到右依次给实参,使用指定的实参
int b = func1(10); // 12
// 带有半缺省参数的函数调用时,没缺省值的形参,必须传实参
int c = func2(100); // 105
// 有缺省值的,可以不给,亦可以从左到右依次给实参
int d = func2(100, 200); // 303
cout << a << " " << b << " " << c << " " << d << endl;
return 0;
}
// 有缺省参数的函数,不能声明和定义同时给缺省值,必须在声明时给缺省值
// Stack.h
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
namespace ST
{
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
// 函数声明时可以使用缺省参数
void Init(ST* ps, int n = 100);
void Push(ST* ps, STDataType x);
}
// Stack.cpp
#include"Stack.h"
namespace ST
{
// 函数定义时,不能有缺省参数
void Init(ST* ps, int n)
{
assert(ps);
ps->a = (STDataType*)malloc(n * sizeof(STDataType));
ps->top = 0;
ps->capacity = n;
}
// 入栈-栈顶
void Push(ST* ps, STDataType x)
{
assert(ps);
// 栈满,扩容
if (ps->top == ps->capacity)
{
// ...
}
// ...
}
}
// test.cpp
#include"Stack.h"
int main()
{
ST::ST st;
// 已知要插入的数据的个数时,可以给定缺省值,这时不用扩容了
ST::Init(&st);
return 0;
}
C++中支持同一作用域中出现同名函数,这样C++函数调用就表现出了多态行为,使用更灵活,但是要求这些同名函数的形参的不同,有以下这些条件才能构成重载。C语言是不支持同一作用域出现同名函数的
1. 形参个数不同
void func();
void func(int a);
2. 形参类型不同
void func(int a);
void func(double b);
3. 形参的顺序不同
void func(int a, double b);
void func(double a, int b);
*注意:以下情况不构成重载
1. 仅函数返回类型不同
// 不构成重载
int func(int a);
double func(int b);
2. 仅参数名不同
// 不构成重载
void func(int a, int b);
void func(int c, int d);
3. 仅 const
修饰符,但不影响参数类型
// 不构成重载
// 这两个函数对于调用者来说,参数类型都是 int
// 对于按值传递的参数,const 只表示在函数内部不能修改 data 的副本,它不改变传递给函数的参数类型
void func(int data);
void func(const int data);
4. 带有全缺省参数的函数和无参函数
// 不构成重载
// 当调用函数时,如果不传实参,编译器无法确认我们要调用那个函数
int func();
int func(int a = 1, int b = 2);
引用不是创建一个新的变量,不会为它分配新的内存空间,引用可以理解为是给一个已经存在的变量取了一个别名,引用和它所绑定的变量共享同一块内存空间。对引用进行的任何操作,本质上都是对它所绑定的变量进行操作。
类型& 引用名 = 引用对象
#incldue<iostream>
using namespace std;
int main()
{
int a = 1;
int& x = a;
x = 100;
cout << a << endl; // 100
cout << a << endl; // 100
// a和x的地址相同
cout << &a << endl;
cout << &x << endl;
return 0;
}
#incldue<iostream>
using namespace std;
int main()
{
int a = 1;
int& x = a;
int& y = x;
int& z = a;
// 任何一个值的改动都会影响其他的值
cout << a << endl; // 1
cout << x << endl; // 1
cout << y << endl; // 1
cout << z << endl; // 1
// 地址都相同
cout << &a << endl;
cout << &x << endl;
cout << &y << endl;
cout << &z << endl;
return 0;
}
// 引用传参
#include<iostream>
using namespace std;
// Swap函数构成了函数重载
void Swap(int* x, int* y)
{
int tmp = *x;
*x = *y;;
*y = tmp;
}
// 形参是实参的别名,形参改变,实参就改变
void Swap(int& ra, int& rb)
{
int tmp = ra;
ra = rb;
rb = tmp;
}
int main()
{
int a = 1;
int b = 2;
Swap(&a, &b);
cout << a << " " << b << endl; // 2 1
Swap(a, b);
cout << a << " " << b << endl; // 1 2
return 0;
}
// 结构体的引用
// 以顺序表为例
// SeqList.h
#include<stdio.h>
#include<stdlib.h>
namespace SLT
{
typedef struct SeqList {
int* a;
int size;// 有效数据个数
int capacity;
}SLT;
// 初始化
void Init(SLT& p, int n = 4);
}
// SeqList.cpp
#include"Seqlist.h"
namespace SLT
{
// 初始化
void Init(SLT& p, int n)
{
p.a = (int*)malloc(sizeof(int) * n);
p.size = p.capacity = 0;
}
}
// test.cpp
#include"Seqlist.h"
int main()
{
SLT::SLT s1;
// 原本应该传指针,因为要改变形参的值
// 这里运用引用传参,形参是实参的别名,形参的改变也会影响实参,比指针更好用
SLT::Init(s1);
return 0;
}
// 指针定义别名
//Swap函数构成了重载
void Swap(int** ppa, int** ppb)
{
int* tmp = *ppa;
*ppa = *ppb;
*ppb = tmp;
}
void Swap(int*& rpa, int*& rpb)
{
int* tmp = rpa;
rpa = rpb;
rpb = tmp;
}
int main()
{
int a = 1;
int b = 2;
int* pa = &a;
int* pb = &b;
Swap(&pa, &pb);
cout << pa << pb << endl;
Swap(pa, pb);
cout << pa << pb << endl;
return 0;
}
// 指针定义别名的应用
// 以单链表为例
// Slist.h
#include<stdio.h>
#include<stdlib.h>
namespace SL
{
typedef struct SListNode {
int val;
struct SListNode* next;
}SLNode;
// 尾插
// 如果链表为空,那么尾插时就会插入头节点,头节点初始化时被定义为空,要改变头节点就是要改变形参
// 那么按照指针的知识,我们应该传二级指针,有了引用之后,我们可以直接传一级指针的引用,更加便捷
// 尾插——引用法
void PushBack(SLNode*& phead, int x);
// 尾插——指针法
void PushBack(SLNode** pphead, int x);
}
// SList.cpp
namespace SL
{
// 尾插——引用法
void PushBack(SLNode*& phead, int x)
{
SLNode* newnode = (SLNode*)malloc(sizeof(SLNode));
// 链表为空
if (phead == NULL)
{
phead = newnode;
}
// 找尾节点,链接
// ...
}
// 尾插——指针法
//void PushBack(SLNode** pphead, int x)
//{
// SLNode* newnode = (SLNode*)malloc(sizeof(SLNode));
// // 链表为空
// if (*pphead == NULL)
// {
// *pphead = newnode;
// }
// // 找尾节点,链接
// // ...
//}
}
// test.cpp
#include"SList.h"
int main()
{
SL::SLNode* plist = NULL;
// 尾插——引法
SL::PushBack(plist, 1);
SL::PushBack(plist, 2);
SL::PushBack(plist, 3);
// 尾插——链接法
SL::PushBack(&plist, 1);
SL::PushBack(&plist, 2);
SL::PushBack(&plist, 3);
return 0;
}
// 用typedef再简化
// SList.h
namespace SL
{
typedef struct SListNode {
int val;
struct SListNode* next;
}SLNode,*PNode;
// 上面等价于下面
// typedef struct SListNode SLNode;
// typedef struct SListNode* PNode;
// 尾插
void PushBack(PNode& pphead, int x);
}
// SList.cpp
#include"SList.h"
namespace SL
{
// 尾插
void PushBack(PNode& phead, int x)
{
PNode newnode = (PNode)malloc(sizeof(SLNode));
// 链表为空
if (phead == NULL)
{
phead = newnode;
}
// 找尾节点,链接
// ...
}
}
// test.cpp
#include"SList.h"
int main()
{
SL::SLNode* plist = NULL;
SL::PushBack(plist, 1);
SL::PushBack(plist, 2);
SL::PushBack(plist, 3);
return 0;
}
在讲解这部分之前我们要清楚一些基本的知识
func1()
被调用时,操作系统会为它在栈 (Stack) 上分配一块内存,我们称之为 “栈帧”。这个栈帧用来存放 func1()
的参数、返回地址以及局部变量(比如 ret
)。return ret;
这行代码返回的不是 ret
的值,而是 ret
变量本身的内存地址的一个别名 (Alias)。func1()
执行完毕并返回后,它所占用的栈帧会被销毁。这意味着,ret
变量所占用的内存空间被释放,归还给操作系统,以便其他函数调用时使用。main
函数中,int& x = func1();
这行代码让引用 x
绑定到了 func1()
返回的那个 “别名” 上。但此时,这个别名所指向的 ret
变量的内存空间已经无效了。我们称这种指向无效内存的引用为 **“悬挂引用” (Dangling Reference)**。#include<iostream>
using namespace std;
// 传引用返回的错误场景
int& func1()
{
//局部变量作返回值是不能用引用返回
int ret = 0;
//...
return ret; //本质相当于返回ret的别名,但是ret的栈帧在函数结束时就已经还给操作系统了
}
// 传值返回
int func2()
{
int y = 123;
//...
return y;
}
int main()
{
int&x = func1(); // x是func1()的别名,func1()返回的又是ret的别名,所以x是ret别名的别名,也就是ret的别名
cout << x << endl; //0 函数结束后,函数栈帧会销毁,但这里的销毁并不是将数据破坏,而是将内存空间还给了操作系统,空间的内容没有被销毁
// 这时,如果我们再去通过引用来访问这块空间,可能是0也可能是随机值
func2(); // 在func2()中局部变量y被创建,并赋值为123。这个y很可能就存放在了之前ret所在的位置
cout << x << endl; //123
return 0;
}
// 跟踪main函数来梳理一下过程
int main()
{
// 1. 调用func1()
// a. func1()的栈帧创建,ret=0。
// b. func1()返回ret的引用(即ret的内存地址)。
// c. func1()执行完毕,其栈帧被销毁,ret的内存空间被释放。
// 2. 绑定引用
// d. 引用x成功绑定到了那个刚刚被释放的内存地址上。
int& x = func1();
// 3. 第一次访问悬挂引用
// e. cout << x << endl;
// f. 此时,虽然ret的内存空间已被释放,但操作系统还没有来得及用新的数据覆盖它。
// g. 因此,当我们通过x访问这块内存时,很可能还能读到旧的值,也就是0。
// h. 注意:这完全是“未定义行为” (Undefined Behavior)。标准C++没有规定这里应该输出什么,它可能是0,也可能是任何随机值。
cout << x << endl; // 可能输出 0
// 4. 调用func2()
// i. func2()被调用,它需要自己的栈帧。
// j. 由于func1()的栈帧已经释放,操作系统很可能将同一块内存分配给func2()使用。
// k. 在func2()中,局部变量y被创建,并赋值为123。这个y很可能就存放在了之前ret所在的位置。
func2();
// 5. 第二次访问悬挂引用
// l. cout << x << endl;
// m. 现在,x引用的那块内存空间里存放的已经不是0了,而是func2()中的y的值123。
// n. 所以,这次输出123。
cout << x << endl; // 在VS编译器上输出 123,不同编译器可能不同
return 0;
}
总结:
绝对不要返回一个函数内部局部变量的引用或指针。这会导致悬挂引用或悬挂指针,引发未定义行为。
如果确实需要通过引用返回以提高效率,那么被返回的变量的生命周期必须长于函数的生命周期。例如,可以返回一个全局变量的引用,或者返回一个由调用者传入的引用参数。
int& func1()
{
static int ret = 0;
//出了作用域这个返回对象还在,才能用引用返回
//...
return ret; //出了函数之后,ret还在,才能用引用返回
}
int func2()
{
int y = 123;
//...
return y;
}
int main()
{
int& x = func1();
cout << x << endl;//0
func2();
cout << x << endl;//0
return 0;
}
可以引用一个const对象,但是必须用const引用。const引用也可以引用普通对象,因为对象的访 问权限在引用过程中可以缩小,但是不能放大。
不需要注意的是类似 int& rb = a*3; double d = 12.34; int& rd = d; 这样⼀些场景下a*3的和结果保存在一个临时对象中, int& rd = d 也是类似,在类型转换中会产生临时对象存储中间值,也就是时,rb和rd引用的都是临时对象,而C++规定临时对象具有常性,所以这里就触发了权限放大,必须要用常引用才可以。
所谓临时对象就是编译器需要一个空间暂存表达式的求值结果时临时创建的⼀个未命名的对象, C++中把这个未命名对象叫做临时对象。
int main()
{
const int a = 0; // a只可读
// 不能权限放大,a自己都不能改变,b是a的别名,却可读可写,这时错误的
//int& b = a; // err
// 不是权限放大,是拷贝赋值,开辟了一块新的空间赋值给e,e的读写不会影响a
int e = a;
const int& b = a; // true
int c = 0; // c类型是int,可读可写
//可以权限缩小
const int& d = c; // d是c的别名,只可读不可写,这时可以的
//权限的缩小和放大,const 指针和引用
const int* p1 = &a;
// 不能权限放大
//int* p2 = p1; // err
const int* p2 = p1; // true
//可以权限缩小
int* p3 = &e;
const int* p4 = p3;
return 0;
}
1)普通对象(非const引用)
int main()
{
int a = 1;
const int& ra = a; // 权限缩小
return 0;
}
2)const引用的对象
int main()
{
const int c = 1;
const int& rc = c; // 必须加const
return 0;
}
3)临时对象(右值)
C++ 标准明确规定:非常量引用(non-const reference)不能绑定到一个右值(rvalue) 什么是右值(rvalue)? 简单来说,右值就是那些可以出现在赋值号 = 右边,但不能出现在左边的表达式。它们通常是临时的、即将销毁的值:
int main()
{
const int& a = 100; // 只读访问,不能修改,必须加const
//当你写下 const int& a = 100; 时,编译器实际做了以下事情:
//1、创建一个临时的 int 对象,并将其值初始化为 100。
//2、将 a 这个引用绑定到这个刚刚创建的临时对象上。
//3、因为 a 是一个 const 引用,编译器启动了 “生命周期延长” 规则。现在,这个临时对象的生命周期不再是当前行,而是被延长到了 a 所在的作用域结束。
// int& a = 100; // err
// a = 200; // err
// 为什么?
// 因为100是数学意义上的,如果不加const,意味着你可以改变它在数学上的含义,这是不可能的
//100 是一个字面量,你不能修改数学意义上的数字 100。我们修改的只是那个临时创建用来存储值 100 的对象
//但这个临时对象在 int& a = 100; 这行代码执行完毕后就会立刻被销毁。
//那么 a 就变成了一个悬垂引用(Dangling Reference),指向一个已经不存在的内存地址。
//后续任何通过 a 的操作都会导致未定义行为(Undefined Behavior),这通常是程序崩溃的根源。
return 0;
}
// 类型转换底层机制
int main()
{
int i = 1;
// 隐式类型转换
double d = i; // i不是直接给d的,i是先把自己的结构转换成浮点型,然后拷贝给一个临时变量,然后临时变量再给d,临时变量具有常性
// 显式类型转换
int p = (int)&i; // 这种也会产生一个临时变量,i会将自身结构转换为int类型拷贝到临时变量中,临时变量具有常性(可以理解为被const修饰)
const double& rd = i; // 不加const错误的核心原因是权限的放大
// 因为i会先转换为浮点型结构,拷贝到临时变量中,而临时变量有具有常性,所以要加const
const int& rp = (int)&i; // 不加const错误的核心原因是权限的放大
// i会先将类型转换为整型,然后拷贝到临时变量中,临时变量在赋值给rp,又因为临时变量具有常性,所以
// 当 const int& ref_d = d; 执行时:
系统会创建一个临时的 int 对象。
将 d 的值(3.14)进行类型转换,截断为 int 值 3,并赋给这个临时对象。
ref_d 引用的就是这个临时的 int 对象。
return 0;
}
// 类型转换时用引用(取别名),要加const
int main()
{
double i = 3.14;
const int& ri = i; // 不加const错误的核心原因是权限的放大
// 因为i会先转换为整型结构,拷贝到临时变量中,而临时变量有具有常性,所以要加const
// 当 const int& ri = i; 执行时:
// 1、系统会创建一个临时的 int 对象。
// 2、将 d 的值(3.14)进行类型转换,截断为 int 值 3,并赋给这个临时对象。
// 3、ri 引用的就是这个临时的 int 对象,临时对象具有常性不能修改,所以要加const
const int& rp = (int)&i; // 不加const错误的核心原因是权限的放大(原因同上)
// i会先将类型转换为整型,然后拷贝到临时变量中,临时变量具有常性,不可修改,rp引用的就是这个临时变量,所以要加const
}
当 const
引用绑定到一个临时对象时,它会延长该临时对象的生命周期,使其与引用本身的生命周期相同。这是一个非常重要的特性,常用于避免不必要的拷贝和悬垂引用,可以提高效率。
void func1(int& x)
{}
// 如果引用传参,不是想让形参的改变影响实参,那么形参前面最好加上const
void func2(const int& y)
{}
int main()
{
const int a = 1;
const int& b = 2;
//func1(a); // err // 权限放大
func2(a); // true
//func1(b);// err // 权限放大
func2(b); // true
//func1(8); // err // 权限放大
func2(8); // true
double d = 3.14;
//func1(d); //err 隐式类型转换,形参前必须加const
func2(d); // true
double* pd = &d;
//func1((int)pd); // err 显示类型转换,形参前必须加const
int z = (int)pd;
func1(z); // true
func2((int)pd); // true
return 0;
}
在底层,引用通常是通过指针来实现的。编译器会将引用处理为常量指针(指向不可改变的指针),虽然从语言层面我们不这么看待它,但为了实现引用的功能,编译器在背后做的事情,和我们使用一个常量指针非常相似。
int x = 10;
int &ref = x;
// 底层类似于: int* const ref = &x;
// const 修饰的是指针 ref 本身,意味着指针 ref 的值(即它所存储的内存地址)是不可更改的。你不能让 ref 再指向其他变量。这对应了引用的 “绑定不可更改” 特性
// 指针 ref 被初始化为变量 x 的地址。这对应了引用的 “必须初始化” 特性。
ref = 20; // 底层类似于: *ref = 20;
那么,为什么我们使用引用时不需要像指针那样用 *
来访问值呢?
这是因为编译器帮我们做了这个工作。当你写下 ref = 20;
时,编译器在编译时会自动将其转换为 *p = 20;
。所以,引用可以看作是一个 **“自动解引用的常量指针”**。
考虑以下代码的汇编实现:
示例代码1:
void example1() {
int x = 10;
int& r = x;
r = 20;
}
汇编实现:
//int x = 10;
//00007FF6E8E219AE mov dword ptr[x], 0Ah 初始化x=10
//int& r = x;
//00007FF6E8E219B5 lea rax, [x] 将x的地址加载到rax
//00007FF6E8E219B9 mov qword ptr[r], rax 将地址存储到引用r中
//r = 20;
//00007FF6E8E219BD mov rax, qword ptr[r] 从r中加载地址
//00007FF6E8E219C1 mov dword ptr[rax], 14h 通过地址修改值为20
示例代码2:
void example2() {
int x = 10;
int* r = &x;
*r = 20;
}
汇编实现:
//int x = 10;
//00007FF705652C6E mov dword ptr[x], 0Ah 初始化x=10
//int* r = &x;
//00007FF705652C75 lea rax, [x] 将x的地址加载到rax
//00007FF705652C79 mov qword ptr[r], rax 将地址存储到引用r中
//* r = 20;
//00007FF705652C7D mov rax, qword ptr[r] 从r中加载地址
//00007FF705652C81 mov dword ptr[rax], 14h 通过地址修改值为20
特性引用指针初始化必须初始化可以不初始化重绑定不能重绑定可以改变指向空值不能为空可以为nullptr内存占用通常不占额外存储占用指针大小内存操作语法编辑器自动解引用需要手动解引用算数运算不支持支持(如p++)用于在内存块中移动 | 特性 | 引用 | 指针 | 初始化 | 必须初始化 | 可以不初始化 | 重绑定 | 不能重绑定 | 可以改变指向 | 空值 | 不能为空 | 可以为nullptr | 内存占用 | 通常不占额外存储 | 占用指针大小内存 | 操作语法 | 编辑器自动解引用 | 需要手动解引用 | 算数运算 | 不支持 | 支持(如p++)用于在内存块中移动 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
特性 | 引用 | 指针 | |||||||||||||||||||
初始化 | 必须初始化 | 可以不初始化 | |||||||||||||||||||
重绑定 | 不能重绑定 | 可以改变指向 | |||||||||||||||||||
空值 | 不能为空 | 可以为nullptr | |||||||||||||||||||
内存占用 | 通常不占额外存储 | 占用指针大小内存 | |||||||||||||||||||
操作语法 | 编辑器自动解引用 | 需要手动解引用 | |||||||||||||||||||
算数运算 | 不支持 | 支持(如p++)用于在内存块中移动 |
C++引用在底层主要通过指针实现,但提供了更安全、更直观的语法接口。理解引用的底层原理有助于:
作为程序员,我们在思考和设计时,应该将引用视为 “别名”,享受它带来的简洁和安全;而在理解底层原理和调试时,要明白它实际上是通过指针机制来实现的。
deug版本为例方便调试,默认不展开内联函数,但是release版本有没法调试,想要观察需要设置debug版本的以下两个地方。
#include<iostream>
using namespace std;
inline int Add(int x, int y)
{
int ret = x + y;
ret += 1;
ret += 1;
ret += 1;
return ret;
}
int main()
{
// 可以通过汇编观察程序是否展开
// 有call Add语句就是没有展开,没有就是展开了
int ret = Add(1, 2);
cout << Add(1, 2) * 5 << endl;
return 0;
}
// SeqList.h
#include<stdio.h>
typedef struct SeqList {
int* arr;
int size;
int capacity;
}SL;
inline void SLInit(SL* plist, int n = 4)//缺省值为4,默认初始化时开辟4个元素大小的空间
{
// ...
}
void SLPushBack(SL* plist, int x);
// SeqList.cpp
#include"SeqList.h"
void SLPushBack(SL* plist, int x)
{
// ...
}
// test.cpp
#include"SeqList.h"
int main()
{
SL s;
//*内联函数必须直接在.h 文件中定义,这时可以直接展开
//声明和定义分离,无法使用内联函数
SLInit(&s); //这里的初始化函数是内联函数,在头文件中只定义了,但是没有实现,所以无法展开,只能call 函数地址,调用函数
//编译的时候是找不到函数的地址的,在链接时去SeqList.cpp生成的.o 文件中找这个函数的地址
//函数的地址是定义时生成的,函数第一句指令的地址就是函数的地址
//编译器认为内联函数在调用的地方展开了,因此内联函数的地址不会放进符号表,编译器在链接时是找不到就会出错
return 0;
}
NULL实际上是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
在 C++11 引入 nullptr
之前,程序员通常使用 0
或 NULL
来表示空指针。然而,这两者都存在一个致命的二义性(Ambiguity)问题,因为它们本质上都是整数。
#include <iostream>
void func(int x) {
std::cout << "调用了 func(int)" << std::endl;
}
void func(char* p) {
std::cout << "调用了 func(char*)" << std::endl;
}
int main() {
foo(0); // 调用 func(int)
foo(NULL); // 期望调用 func(char*),但实际上可能调用 foo(int),甚至无法编译
return 0;
}
foo(0)
毫无疑问会调用 foo(int)
。foo(NULL)
的行为是未定义的,因为 NULL
在 C++ 中通常被定义为 0
或 ((void*)0)
。 NULL
被定义为 0
,那么 foo(NULL)
会像 foo(0)
一样调用 foo(int)
,这违背了我们想用 NULL
表示 “空指针” 的初衷。NULL
被定义为 ((void*)0)
,在 C++ 中不能直接将 void*
隐式转换为 char*
等其他指针类型,代码会编译失败。nullptr
的定义与特性为了解决上述问题,C++11 引入了一个新的关键字 nullptr
。
nullptr
是一个关键字,它的类型是 std::nullptr_t
。这个类型定义在 <cstddef>
头文件中。nullptr
可以被隐式转换为任何指针类型(包括裸指针和智能指针)和任何成员指针类型。但是,它不能被隐式转换为整数类型。nullptr
是一个编译期常量,代表一个 “空” 的指针值。使用 nullptr
可以清晰地告诉编译器我们想要传递一个空指针.
#include <iostream>
void func(int x) {
std::cout << "调用了 func(int)" << std::endl;
}
void func(char* p) {
std::cout << "调用了 func(char*)" << std::endl;
}
int main() {
foo(0); // 调用 func(int)
foo(nullptr); // 明确调用 foo(char*)
return 0;
}
nullptr
的类型是 std::nullptr_t
,它能被隐式转换为 char*
,但不能转换为 int
,因此编译器会选择 foo(char*)
版本。
如有不足或改进之处,欢迎大家在评论区积极讨论,后续我也会持续更新C++相关的知识。文章制作不易,如果文章对你有帮助,就点赞收藏关注支持一下作者吧,让我们一起努力,共同进步!
我的博客即将同步至腾讯云开发者社区,邀请大家一同入驻:https://cloud.tencent.com/developer/support-plan?invite_code=ha7lxl2jpkr