引言
本篇文章从C++的起源与发展入手,主要讲解C++中的一些基础的语法及一些关键字,为之后学习类和对象打下一个坚实的基础。
一. C++的起源与发展
1.1 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语言中的不足
1.2 C++的版本更新


1.3 第一个C++程序
// test.cpp
#include<iostream>
using namespace std
int main()
{
cout<< "Hello world" <<endl;
return 0;
}
看到这个程序,我们肯定对其有所不解,头文件为什么不加 .h 后缀,下面的 using 又是什么,这些都会在接下来的文章中进行讲解。
二. 命名空间(namespace)
在我们之前学习的C语言中,变量和函数等都是在全局作用域或局部作用域中存在的,而随着代码量的不断增大,我们会创建大量的变量和函数,这时如果都存放在全局或main函数中,很容易产生命名冲突,命名空间(namespace)的出现就是为了解决这个问题
2.1 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;
}
2.2 namespace的定义
- 定义命名空间需要用到namespace 关键字,然后后面跟命名空间的名字,然后接一对{}即可,{}中即为命名空间的成员。命名空间中可以定义变量/函数/类型等
- 命名空间namespace的本质是定义了一个新的域,不是局部,也不是全局;域中定义的变量生命周期是全局
- namespace只能定义在全局,并且可以嵌套使用
- C++中有函数局部域,全局域,命名空间域,类域,不同的域可以定义同名变量;域影响的是编译时语法查找一个变量/函数/ 类型出处(声明或定义)的逻辑,所有有了域隔离,名字冲突就解决了;命名空间域和类域不会影响变量的生命周期
- 项目工程中多文件定义的同名namespace编译器会认为是一个namespace,不会冲突
- C++标准库都放在一个叫std(standard)的命名空间中,防止创建的变量名与库中函数名相同,产生命名冲突
2.2.1 正常命名空间的定义
#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.2.2 命名空间的嵌套调用
// 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;
}
2.2.3 多个文件定义同名namespace,它们会默认合并到一起,就像在同一个namespace一样
// 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;
}
2.3 命名空间的使用
编译查找一个变量的声明/定义时,默认只会在局部或者全局查找,不会到命名空间里面去查找。
所以我们要使用命名空间中定义的变量/函数,有三种方式:
- 指定命名空间访问,项目中推荐这种方式。
- using将命名空间中某个成员展开,项目中经常访问的不存在冲突的成员推荐这种方式。
- 展开命名空间中全部成员,项目不推荐,冲突风险很大,小练习程序为了方便推荐使用。
#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;
}
三. C++输入和输出
- <iostream>是 Input Output Stream 的缩写,是标准的输入、输出流库,定义了标准的输入、输出对象。
- std::cin 是 istream 类的对象,它主要面向窄字符(narrow characters (of type char))的标准输入流。
- std::cout 是 ostream 类的对象,它主要面向窄字符的标准输出流。
- C++ 的输入 (Input) 是将外部的字符串(或更准确地说是字符流)解析并转换为变量在内存中实际存储的二进制值。
- C++的输出 (Output) 才是将变量在内存中的二进制值格式化为字符串,然后发送到屏幕或文件。
- std::endl 是一个函数,流插入输出时,相当于插入一个换行字符加刷新缓冲区。
- <<是流插入操作符,>>是流提取运算符。(C语言还用这两个运算符做位运算左移/右移)
- 使用C++输入输出更方便,不需要像printf/scanf输⼊输出时那样,需要手动指定格式,C++的输⼊ 输出可以自动识别变量类型(本质是通过函数重载实现的,这个以后会讲到),其实最重要的是 C++的流能更好的支持自定义类型对象的输入输出。
- IO流涉及类和对象,运算符重载、继承等很多面向对象的知识,所以这里我们只简单认识⼀下C++ IO流的用法。
- cout/cin/endl等都属于C++标准库,C++标准库都放在⼀个叫std(standard)的命名空间中,所以要 通过命名空间的使用方式去用他们。
- ⼀般日常练习中我们可以using namespace std,实际项目开发中不建议using namespace std。
- 这里我们没有包含,也可以使用printf和scanf,在包含<iostream>间接包含了。vs系列编译器是这样的,其他编译器可能会报错。
#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;
}
四. 缺省参数
- 缺省参数就是在声明或定义函数时,给形参一个缺省值(默认值)。如果在调用该函数时,没有传实参,那么该函数就会使用该形参的缺省值,否则就使用给定的实参。其中缺省参数分为全缺省参数和半缺省参数(有些地方把缺省函数也叫做默认函数)
- 全缺省就是给全部的行参缺省值,半缺省就是给部分形参缺省值。C++规定,半缺省参数必须从右往左依次缺省,不能跳跃给缺省值
- 带缺省参数的函数调用时,C++规定,必须从左到右依次传实参,不能跳跃给实参,缺省参数没传实参的部分就默认使用缺省值。如果是全缺省,可以不传实参,也可以从左到右依次给实参;如果是半缺省传实参,没有缺省值的形参就必须传实参,缺省的部分可以不给实参,也可以从左到右依次给实参
- 带缺省参数的函数声明和定义分离时,缺省参数不能在函数声明和定义时同时出现,C++规定,只能在函数声明时给缺省值,定义时不能给
// 全缺省参数
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);
六. 引用
6.1 引用的概念和定义
引用不是创建一个新的变量,不会为它分配新的内存空间,引用可以理解为是给一个已经存在的变量取了一个别名,引用和它所绑定的变量共享同一块内存空间。对引用进行的任何操作,本质上都是对它所绑定的变量进行操作。
类型& 引用名 = 引用对象
#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;
}
6.2 引用的特性
- 引用定义时必须初始化
- 一个变量可以有多个引用
- 引用一旦引用一个实体,再不能引用其他实体(引用一旦被初始化为某个变量的别名,就不能再被改为其他变量的别名)
#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;
}
6.3 引用的使用
- 引用在时间中主要用于引用传参和引用传作返回值中较少拷贝效率和改变引用对象的同时改变被引用的对象
- 引用传参与指针传参类似,引用传参相对更简便也更容易理解一些
- 引用和指针在实践中相辅相成,功能具有重叠性,但各有各的特点,互相不可替代。C++的引用跟其他语言的引用(如Java)是有很大的区别的,除了用法,最大的点,C++引用定义后不能改变指向,Java的引用可以改变指向。
(1)引用传参
// 引用传参
#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;
}
(2)引用作返回值
在讲解这部分之前我们要清楚一些基本的知识
- 栈帧 (Stack Frame): 当
func1()被调用时,操作系统会为它在栈 (Stack) 上分配一块内存,我们称之为 “栈帧”。这个栈帧用来存放func1()的参数、返回地址以及局部变量(比如ret)。 - 返回引用:
return ret;这行代码返回的不是ret的值,而是ret变量本身的内存地址的一个别名 (Alias)。 - 函数结束: 当
func1()执行完毕并返回后,它所占用的栈帧会被销毁。这意味着,ret变量所占用的内存空间被释放,归还给操作系统,以便其他函数调用时使用。 - 悬挂引用: 在
main函数中,int& x = func1();这行代码让引用x绑定到了func1()返回的那个 “别名” 上。但此时,这个别名所指向的ret变量的内存空间已经无效了。我们称这种指向无效内存的引用为 **“悬挂引用” (Dangling Reference)**。
a. 传引用返回的错误场景
#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;
}
总结:
绝对不要返回一个函数内部局部变量的引用或指针。这会导致悬挂引用或悬挂指针,引发未定义行为。
如果确实需要通过引用返回以提高效率,那么被返回的变量的生命周期必须长于函数的生命周期。例如,可以返回一个全局变量的引用,或者返回一个由调用者传入的引用参数。
b. 传引用返回的正确场景
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;
}
6.4 const引用
可以引用一个const对象,但是必须用const引用。const引用也可以引用普通对象,因为对象的访 问权限在引用过程中可以缩小,但是不能放大。
不需要注意的是类似 int& rb = a*3; double d = 12.34; int& rd = d; 这样⼀些场景下a*3的和结果保存在一个临时对象中, int& rd = d 也是类似,在类型转换中会产生临时对象存储中间值,也就是时,rb和rd引用的都是临时对象,而C++规定临时对象具有常性,所以这里就触发了权限放大,必须要用常引用才可以。
所谓临时对象就是编译器需要一个空间暂存表达式的求值结果时临时创建的⼀个未命名的对象, C++中把这个未命名对象叫做临时对象。
6.4.1 特性1:只读访问(注意权限的放大缩小)
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;
}
6.4.2 特性2:可以绑定到不同类型的对象
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)?
简单来说,右值就是那些可以出现在赋值号 = 右边,但不能出现在左边的表达式。它们通常是临时的、即将销毁的值:
- 字面量,如 100, 3.14, "hello"。
- 表达式的计算结果,如 a + b。
- 函数返回的临时对象。
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
}
6.4.3 特性3:延长生命周期
当 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;
}
6.5 引用底层原理及与指针比较分析
6.5.1 引用与指针的相似性
在底层,引用通常是通过指针来实现的。编译器会将引用处理为常量指针(指向不可改变的指针),虽然从语言层面我们不这么看待它,但为了实现引用的功能,编译器在背后做的事情,和我们使用一个常量指针非常相似。
int x = 10;
int &ref = x;
// 底层类似于: int* const ref = &x;
// const 修饰的是指针 ref 本身,意味着指针 ref 的值(即它所存储的内存地址)是不可更改的。你不能让 ref 再指向其他变量。这对应了引用的 “绑定不可更改” 特性
// 指针 ref 被初始化为变量 x 的地址。这对应了引用的 “必须初始化” 特性。
ref = 20; // 底层类似于: *ref = 20;
那么,为什么我们使用引用时不需要像指针那样用 * 来访问值呢?
这是因为编译器帮我们做了这个工作。当你写下 ref = 20; 时,编译器在编译时会自动将其转换为 *p = 20;。所以,引用可以看作是一个 **“自动解引用的常量指针”**。
6.5.2 汇编层面分析
考虑以下代码的汇编实现:
示例代码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
6.5.3 引用与指针的区别
|
- 语法概念上引用是⼀个变量的取别名不开空间,指针是存储⼀个变量地址,要开空间。
- 引用在定义时必须初始化,指针建议初始化,但是语法上不是必须的。
- 引用在初始化时引用一个对象后,就不能再引用其他对象;而指针可以在不断地改变指向对象
- 引用可以直接访问指向对象,指针需要解引用才是访问指向对象。
- sizeof中含义不同,引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下 占4个字节,64位下是8byte)
- 指针很容易出现空指针和野指针的问题,引用很少出现,引用使用起来相对更安全一些。
6.5.4 总结
C++引用在底层主要通过指针实现,但提供了更安全、更直观的语法接口。理解引用的底层原理有助于:
- 写出更高效的代码
- 避免潜在的错误
- 更好地理解C++对象模型
- 在需要时进行适当的优化
作为程序员,我们在思考和设计时,应该将引用视为 “别名”,享受它带来的简洁和安全;而在理解底层原理和调试时,要明白它实际上是通过指针机制来实现的。
七. inline内联函数
- 用inline修饰的函数叫做内联函数,编译时C++编译器会在调用的地方展开内联函数,这样调用内联函数就需要建立栈帧了,就可以提高效率。
- inline对于编译器而言只是⼀个建议,也就是说,你加了inline编译器也可以选择在调用的地方不展 开,不同编译器关于inline什么情况展开各不相同,因为C++标准没有规定这个。inline适用于频繁调用的短小函数,对于递归函数,代码相对多⼀些的函数,加上inline也会被编译器忽略。
- C语言实现宏函数也会在预处理时替换展开,但是宏函数实现很复杂很容易出错的,且不方便调 试,C++设计了inline目的就是替代C的宏函数。
- vs编译器,debug版本下面默认是不展开inline的,这样方便调试,debug版本想展开需要设置以下两个地方。
- inline不建议声明和定义分离到两个文件,分离会导致链接错误。因为inline被展开,就没有函数地 址,链接时会出现报错。
deug版本为例方便调试,默认不展开内联函数,但是release版本有没法调试,想要观察需要设置debug版本的以下两个地方。


7.1 内联函数是否展开可以在调试时通过汇编指令来查看
#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;
}
7.2 内联函数声明和定义不能分离
// 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;
}
八. nullptr
NULL实际上是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
- C++中 NULL 可能被定义为字面常量0,或者C中被定义为无类型指针(void*)的常量。不论采取何种 定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,本想通过f(NULL)调⽤指针版本的 f(int*)函数,但是由于NULL被定义成0,调用了f(int x),因此与程序的初衷相悖。f((void*)NULL); 调用会报错。
- C++11中引图入nullptr,nullptr是一个特殊的关键字,nullptr 是一种特殊类型的字面量,它可以转换 成任意其他类型的指针类型。使用 nullptr 定义空指针可以避免类型转换的问题,因为nullptr只能被隐式地转换为指针类型,而不能被转换为整数类型。
在 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
3295

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



