本文主要介绍c++的一些入门知识,为后面打基础
文章目录
前言
1:C++语言建立在C的基础之上。C++ 容纳进去了面向对象编程思想,并增加了许多有用的库,以及编程范式等。这些使得 C++ 更加强大。
2:C++补充了C语言很多语法上的不足,以及对C语言设计不合理的地方上进行了优化,例如:作用域、IO方面、函数方面、指针方面、宏方面等。
1、C++关键字(C++98)
C++总计63个关键字,C语言32个关键字
2、命名空间
命名空间的作用:在C/C++中,变量、函数和后面要学到的类都是大量存在的,这些变量、函数和类的名称将都存在于全局作用域中,可能会导致很多冲突。使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突或名字污染。
我们来看一个例子:
#include <stdio.h>
#include <stdlib.h>
int rand = 10;
// C语言没办法解决类似这样的命名冲突问题,所以C++提出了namespace来解决
int main()
{
printf("%d\n", rand);
return 0;
}
// 编译后后报错:error C2365: “rand”: 重定义;以前的定义是“函数”
解释:上述代码再不包含#include <stdlib.h>
是可以运行的,但是包含了之后就会报错了,因为rand再<stdlib.h>
里面是一个函数,此时会导致命名冲突。
2.1 命名空间定义
//域名:mwq
namespace mwq
{
}
定义命名空间,需要使用到namespace关键字,后面跟命名空间的名字,然后接一对{}即可,{}中即为命名空间的成员
我们定义出的命名空间就像一个域,就像局部域和全局域一样,每个域之间不相互影响,我们可以把命名空间叫做命名空间域。
命名空间域只影响使用,不影响生命周期。
所以在不同的 namespace
中的成员就不会互相冲突。
命名空间有 四个特点 :
- 命名空间名字不受限定,可以随机取
- 命名空间中可以定义变量/函数/类型,十分自由
- 命名空间可以嵌套定义
- 若同一工程中,命名空间名字相同,最终会被合并为一个命名空间,此时就几乎丧失了命名空间的作用,因为在这里面命名冲突存在的话依然会报错
下面逐个演示一下:
1、2特点:
namespace mwq// 名字随便取
{
int val = 10; //变量
int add(int left,int right) //函数
{
return left + right;
}
}
3特点:
namespace mwq
{
int val = 10;
namespace yaya //嵌套
{
int add(int left,int right)
{
return left + right;
}
}
}
4特点:
在工程中,用定义同样的命名空间:
在.c
文件中包含头文件,编译运行时,两个命名空间就会合并:
这里就相当于用test2.h 中的 print 函数将 test1.h 中的 max 打印了出来。
2.2 命名空间使用
命名空间的使用有三种方式:
- 指定命名空间-加命名空间名称及作用域限定符,常用,一般自己定义一个域,自己访问
- 全局展开-使用using namespace 命名空间名称 引入
- 部分展开-使用using将命名空间中某个成员引入
1.指定命名空间
每次使用的时候,我们都使用域作用符::
表明他的域
::
的左边为域,如果有命名空间域,则限定访问命名空间域中的内容,如果域左边为空,访问的就是全局域,会直接到全局范围内找::
右边的变量或其他。
namespace Queue
{
struct Node
{
struct QNode* next;
int val;
};
struct QueueNode
{
struct Node* head;
struct Node* tail;
};
void QueueInit(struct QueueNode* q)
{
}
void QueuePush(struct QueueNode* q,int x)
{
}
}
int main()
{
struct Queue::QueueNode q;
Queue::QueueInit(&q);
Queue::QueuePush(&q,1);
Queue::QueuePush(&q,2);
}
2.全局展开(一般情况,不建议全局展开)
我们用一次展开一次,太繁琐了,我们怎样可以简单一点呢?
就是用全局展开,
代码为using namespace + 域名
namespace Queue
{
struct Node
{
struct QNode* next;
int val;
};
struct QueueNode
{
struct Node* head;
struct Node* tail;
};
void QueueInit(struct QueueNode* q)
{
}
void QueuePush(struct QueueNode* q,int x)
{
}
}
using namespace Queue;
int main()
{
struct QueueNode q;
QueueInit(&q);
QueuePush(&q,1);
QueuePush(&q,2);
}
所以我们写
C++
,代码的时候,
我们要加上一句using namespace std
,将我们库文件的东西全局展开。
但是这种方法不太好,将我们好不容易建立的库,就有展开了,相当于没有命名空间了
所以我们以后写项目,禁止这样写代码,但是我们在前期学习阶段,我们是可以这样的,因为这样很方便。
3.部分展开
和全局展开类似,只不过我们使用哪个就展开哪个
using std::cout;
例如我们需要用cout
,但是cout
在std
的域内,所以我们使用域作用限定符进行限定,这样我们在后面都可以使用cout
了
注意:std是C++标准库的命名空间名,C++将标准库的定义实现
#include<iostream>
//常用的展开
using std::cout;都放到这个命名空间中
using std::endl;
int main()
{
cout << "1111" << endl;
cout << "1111" << endl;
cout << "1111" << endl;
cout << "1111" << endl;
int i = 0;
std::cin >> i;
}
3、C++输入&输出
cin
:标准输入流(想象成scanf
)
cout
:标准输出流(想象成printf
)
优势:
- 可以自动识别变量类型
- 不用想我们的
printf
,scanf
需要指定类型,%d
,%f
,但是我这个就不用,他自动识别类型。
说明:
- 使用
cout
标准输出对象(控制台)和cin
标准输入对象(键盘)时,必须包含< iostream >
头文件以及按命名空间使用方法使用std
。cout和cin
是全局的流对象,endl是特殊的C++符号,表示换行输出,他们都包含在包含头文件中。- <<是流插入运算符,>>是流提取运算符。
- 使用C++输入输出更方便,不需要像
printf/scanf
输入输出时那样,需要手动控制格式。C++的输入输出可以自动识别变量类型。- 实际上
cout和cin分别是ostream和istream
类型的对象,>>和<<也涉及运算符重载等知识
cin和cout提速技巧:
由于 C++ 需要兼容 C ,所以需要保证一些缓冲区等的同步,所以有时 cin 和 cout 速度会相对较 scanf 和 printf 较慢,所以可以通过关掉同步来对 cin 和 cout 进行提速,写算法题时可以用
ios::sync_with_stdio(false); // 关掉同步,提速 cin
cout.tie(NULL); // 提速 cout
4、缺省参数
4.1 缺省参数定义
缺省参数是声明或定义函数时为函数的参数指定一个缺省值。在调用该函数时,如果没有指定实参则采用该形参的缺省值,否则使用指定的实参。
这里就相当于给参数提供了一个缺省值,如果不进行传参,就会直接使用缺省参数的缺省值;如果传参,则使用传递的参数。
而缺省参数又分为两类:全缺省参数 和 半缺省参数 。
4.2 全缺省参数
特点:全缺省参数就是所有参数都具有缺省值。
void Func(int a = 10, int b = 20, int c = 30)
{
cout<<"a = "<<a<<endl;
cout<<"b = "<<b<<endl;
cout<<"c = "<<c<<endl;
}
4.3 半缺省参数
特点:半缺省参数也叫部分缺省,必须从右往左连续缺省
void Func1(int a, int b = 10, int c = 20) //正确
{
cout<<"a = "<<a<<endl;
cout<<"b = "<<b<<endl;
cout<<"c = "<<c<<endl;
}
//错误,笔试从右向左连续缺省
void Func(int a = 20, int b = 10, int c)
{
cout<<"a = "<<a<<endl;
cout<<"b = "<<b<<endl;
cout<<"c = "<<c<<endl;
}
注意:
- 半缺省参数必须从右往左依次来给出,不能间隔着给
- 缺省参数不能在函数声明和定义中同时出现,我们定义缺省值,都是在声明的时候给的,即在.h文件中。因为我们
man.c
调用的时候包含的是头文件。并且声明的函数都没有缺省值,那么定义的函数又怎么会有呢
//a.h
void Func(int a = 10);
// a.cpp
void Func(int a = 20)
{}
// 注意:如果生命与定义位置同时出现,恰巧两个位置提供的值不同,那编译器就无法确定到底该用那个缺省值
- 缺省值必须是常量或者全局变量
- C语言不支持(编译器不支持)
4.4 缺省参数的优点
缺省参数让函数使用更加灵活,就拿之前我们数据结构的例子来说,比如我们当初写栈时,当栈初始化时,可以开辟空间,也可以不开辟空间。
#include <iostream>
using namespace std;
struct Stack
{
int* a;
int top;
int capacity;
};
void StackInit(struct Stack* p)
{
p->a = (int*)malloc(sizeof(int) * 100); // 空间开定 100
p->top = 0;
p->capacity = 100;
}
int main()
{
Stack st;
StackInit(&st);
return 0;
}
这种写法有一个缺点,就是空间写定了,就只能是开 100 个整形空间;如果想开辟两个大小不同的栈就没办法了,开大了浪费,开小了不够用。
实在没办法就是再增加一个参数。可是增加参数,如果对于无需求传参的使用者来说,又是一件麻烦事,所以也不太可行。
这种写法有一个缺点,就是空间写定了,就只能是开 100 个整形空间;如果想开辟两个大小不同的栈就没办法了,开大了浪费,开小了不够用。
但是如果这时使用缺省参数,就可以解决这个问题:
5 、函数重载
5.1 函数重载概念
这也是对C语言的一个补充,对于c语言是不允许重名函数的存在的,当函数名字相同时,就会报错。但是对于 c++ 可以。
函数重载:是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表(参数个数 或 类型 或 类型顺序)不同,常用来处理实现功能类似数据类型不同的问题。
5.2 函数重载规则
当函数重载条件满足如下三条时,则可以构成函数重载:
- 参数类型不同
- 类型的顺序不同!!!
- 参数的个数不同
// 参数类型不同
int add(int left, int right)
{
return left + right;
}
int add(double left, double right)
{
return left + right;
}
// 参数个数不同
int add(double left, double right, int mid)
{
return left + right;
}
// 参数类型顺序不同
int add(int left, char right)
{
return left + right;
}
int add(char right, int left)
{
return left + right;
}
注意:
- 函数重载需要在同一个命名空间。
- 对于相同类型的数据,顺序不同,不构成函数重载,因为编译器无法识别:
int add(int right, int left)
{
return left + right;
}
int add(int left, int right)
{
return left + right;
}
要想实现函数重载,必须是上面三种的其中一种情况
5.3 函数重载原理
为什么对于相同类型的数据,顺序不同,不构成函数重载呢?
这我们就要搞清除函数重载的规则了,在这之前我们要明白编译器是如何识别函数的!
对于编译而言,调用函数处会变成
call + add(地址)
的形式,然后通过汇编指令完成调用
注:由于windows下的修饰规则比较乱,这里我们就拿linux下来进行演示,本质是一样的,修饰规则是存在的
5.3.1 linu下函数名的修饰规则
Linux 下修饰规则(重要):格式:_ Z + 函数名称长度 + 函数名 + 类型首字母
int add(int left, int right)
{
return left + right;
}
例如:这个函数就会被修饰为: _Z3addii
- _Z 是前缀
- 3 是函数名长度
- ii 代表参数类型的首字母
当编译时,就拿修饰以后的函数的名字去找,找到了就可以调用了。所以只要参数类型,个数,顺序不同均可以满足,因为此刻修饰后的函数名是可以被区分的。
从这里也可以看出为什么参数类型相同但是顺序不同不可以构成重载:因为识别不了。
5.3.2 证明修饰规则
核心:使用objdump -S exeName
查看修饰规则,exeName
为可执行程序名称:
证明如下:
#include <iostream>
using namespace std;
int add(int left, int right) {}
int main()
{
return 0;
}
1:编译自定义名称为 mytest :
2:使用objdump -S exeName
查看修饰规则,exeName
为可执行程序名称:
发现名字是符合修饰规则的。
修改代码,再次验证:
#include <iostream>
using namespace std;
int add(int left, int right) {}
int adddd(int left, double right) {}
char subb(double* left, int right) {} // 验证指针
int main()
{
return 0;
}
对于指针参数,则会在参数类型首字母前加上大写P修饰,P代表point,表示它是个指针 :
对于相同名字的函数,函数重载就根据参数的类型,顺序,个数,以这些为基准,来区别不同的函数。
而根据上面的验证,我们也知道为什么 返回值不同 和 参数类型相同但顺序不同 为什么不能构成函数重载的原因:
- 因为参数类型相同,但是顺序不同最后形成的后缀还是一样的,都是类型的首字母,并不能区分该调用哪个函数;
- 而对于返回值不同的其他都相同的函数来说,则是因为分不清调用哪个函数,不仅仅是因为函数返回值不在修饰规则内。
比如 int add()
和double add()
,在函数调用时,我该调用哪个?编译器在这时候就错乱了,根本上是语法层面的问题
5.3.4 函数重载原理总结
- 实际项目通常是由多个头文件和多个源文件构成,我们知道,【当前a.cpp中调用了b.cpp中定义的Add函数时】,编译后链接前,a.o的目标
文件中没有Add的函数地址,因为Add是在b.cpp中定义的,所以Add的地址在b.o中。那么怎么办呢? - 所以链接阶段就是专门处理这种问题,链接器看到a.o调用Add,但是没有Add的地址,就会到b.o的符号表中找Add的地址,然后链接到一起。
- 那么链接时,面对Add函数,链接接器会使用哪个名字去找呢?这里每个编译器都有自己的 函数名修饰规则。
- 由于Windows下vs的修饰规则过于复杂,而Linux下g++的修饰规则简单易懂,下面我们看g++演示了这个修饰后的名字。
- 通过下面我们可以看出gcc的函数修饰后名字不变。而g++的函数修饰后变成【_Z+函数长度 +函数名+类型首字母】。
- 通过这里就理解了C语言没办法支持重载,因为同名函数没办法区分。而C++是通过函数修饰规则来区分,只要参数不同,修饰出来的名字就不一样,就支持了重载、
- 如果两个函数函数名和参数是一样的,返回值不同是不构成重载的,因为调用时编译器没办法区分。
6 、引用
6.1 什么是引用?
引用不是新定义一个变量,而是给已存在变量取了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。
格式如下:
类型& 引用变量名(对象名) = 引用实体;
例如:
void TestRef()
{
int a = 10;
int& ra = a;//<====定义引用类型,ra和a的地址一样
printf("%p\n", &a);
printf("%p\n", &ra);
}
运行结果:ra和a的地址一样
图解:
注意:引用类型必须和引用实体是同种类型的*(后面我们直接用auto)
6.2 引用的特点
1. 引用在定义时必须初始化
2. 一个变量可以有多个引用
3. 引用一旦引用一个实体,再不能引用其他实体
🐯特点1:引用在定义时必须初始化
引用是取别名,所以在定义的时候必须明确是谁的别名。
🐯特点2:一个变量可以有多个引用
就和李逵一样,他可以叫黑旋风也可以叫铁牛,这都是它。
所以一个变量也可以有多个别名。
而对于一个起过别名的变量,对它的别名取别名也是可以的。
比如上面对a起了一个别名b,我们还可以对b再起一个别名c。
而从根本上看,就可以这么理解:
但是别名不能和正式名字冲突,就比如取过别名,就不能定义和别名重命的变量,即使它们的类型不同。
但是这里的报错信息并不准确,实际上为命名冲突。
🐯特点3:引用一旦引用一个实体,就不能引用其他实体
我们来看一个有意思的代码:
int main()
{
int a = 10;
int& b = a;
int c = 20;
b = c;
return 0;
}
对于上面的代码,有什么含义?
- 让 b 变成 c 的别名?
- 还是把 c 赋值给 b ?
这里的代码意思是第二个含义,就是赋值,我们调试看看
调试我们也可以看到,我们只是把 c 的值赋值给了 b ,b 的地址还是没变的 ,并且 a 的值也改变了,因为我们b是a的别名,改变b的值就会改变a的值
这就说明引用一旦引用一个实体,就不能引用其他实体,引用是不会发生改变的。
但是对于指针,则是截然不同的:
int main()
{
int a = 10;
int c = 20;
int* p = &a;
p = &c;
return 0;
}
对于指针来说,指针可以随时修改:
p原本指向 a ,现在指向 c .
但是引用也有局限性,因为引用之后的变量是不可修改引用的,比如链表,节点是要不断更替迭代的,所以还需要指针配合,C++才可以写出一个链表。
6.3 引用的使用场景(怎么用)
a、 做参数
我们知道形参的改变不影响实参,所以这种写法并不能改变值,因为此刻是 传值调用
按照之前 c 的写法,我们使用 传址调用 ,用指针修改:
但是学习引用之后,完全可以用引用修改:
x 和 y 分别是 a 和 b 的引用,对 x 和 y 进行修改,就是对 a 和 b 进行修改,所以值也被修改成功了。
调试看一下:
它们的地址是完全相同的。而这里这里既不是传值调用,也不是传址调用,而是 传引用调用 。
思考:上面三个函数是否构成函数重载?
答:构成,但无法调用。
- 根据函数名修饰规则,传值和传引用的是不一样的,比如会加上 R 做区分。
- 但是不能同时调用传值和传引用,因为有歧义,因为 调用不明确 ,编译器并不知道调用哪个:
引用解决二级指针生涩难懂的问题 :
讲单链表时,我们写的由于是没有头结点的链表,所以修改时,需要二级指针,对于指针概念不清晰的小伙伴们可能比较难理解。
但是学了引用,就可以解决这个问题:
结构定义:
typedef struct SListNode
{
int data;
struct SListNode* next;
}SLTNode;
原代码:
void SListPushFront(SLTNode** pphead, SLTDateType x)
{
SLTNode* newnode = BuyListNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
// 调用
SLTNode* pilst = NULL;
SListPushFront(&plist);
修改后:
void SListPushFront(SLTNode*& pphead, SLTDateType x) // 这里的pphead就是传过来的参数的别名
{
SLTNode* newnode = BuyListNode(x);
newnode->next = *pphead;
*pphead = newnode;
}
// 调用
SLTNode* pilst = NULL;
SListPushFront(plist); // 改
修改之后的代码里的二级指针被替换成了引用。
而这里的意思就是给一级指针取了一个别名,传过来的是plist
,而plist
是一个一级指针,所以会出现 *
,而这里就相当于pphead
是 plist
的别名。而这里修改 pphead
,也就可以对plist
完成修改。
但是有时候也会这么写 :
结构改造:
typedef struct SListNode
{
int data;
struct SListNode* next;
}SLTNode, *PSLTNode;
这里的意思就是将 struct SListNode*
类型重命名为PSLTNode
。
typedef struct SListNode* PSLTNode;
代码:
void SListPushFront(PSLTNode& pphead, SLTDateType x) // 改
{
PSLTNode newnode = BuyListNode(x);
newnode->next = pphead;
pphead = newnode;
}
// 调用
PSLTNode plist = NULL;
SListPushFront(plist);
在 typedef
之后,PSLTNode
就是结构体指针,所以传参过去,只需要在形参那边用引用接收,随后进行操作,就可以达成目的。
而形参的改变影响实参的参数叫做输出型参数,对于输出型参数,使用引用十分舒适。
b、 做返回值
引用返回的原则:如果函数返回时,出了函数作用域,如果返回对象还在(还没还给系统),则可以使用引用返回,如果已经还给系统了,则必须使用传值返回。
提炼:如果出了作用域,返回变量(静态,全局,上一层栈帧,malloc等)仍然存在,则可以使用引用返回。
要搞清楚这一块,我们先进行一些铺垫。
int add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int ret = add(1, 2);
cout << ret << endl;
return 0;
}
这里看似很简单,就是把add函数计算结束的结果返回,但是这里包含了 传值返回 。
若从栈帧角度看,会先创建 main 函数的栈帧,里面就会有 call 指令,开始调用 add 函数。而 add 函数也会形成栈帧,而栈帧中也有两块小空间,用来接受参数,分别为 a 和 b,而里面的 c 则用来计算结果并返回。
而对于传值返回,返回的并不是 c ,而是返回的是 c 的拷贝。而这其中会有一个临时变量,返回的是临时变量(见函数栈帧)
如果返回的是 c 的话,由于 add 的函数栈帧已经销毁了,就会产生很多奇怪的问题。c 能不能取到都是未知,而这时都是非法访问,因为空间已经被归还给系统了,所以必定是c拷贝后的数据被返回。
但是临时变量在哪?
- 如果 c 比较小(4/8 byte),一般是寄存器充当临时变量,例如eax
- 如果 c 比较大,临时变量放在调用 add 函数的栈帧中
最后将临时变量中的值赋值给ret
所有的传值返回都会生成一个拷贝
便于理解,看一下汇编:
看第四句话,这里是说,把 eax 中的值,拷贝到 ret 中。
而再函数调用返回时:
这里是将 c 的值放到 eax 中的。
这也就印证了返回时,是以临时拷贝形式返回的,由于返回值是 int (4/8个字节,较小),所以是直接用的 eax 寄存器。
而不论这个函数结束后,返回的那个值会不会被销毁,都会创建临时变量返回,例如这段代码 :
int c()
{
static int n = 0;
n++;
return n;
}
int main()
{
int ret = c();
cout << ret << endl;
return 0;
}
对于该函数,编译器仍然是创建临时变量返回;因为编译器不会对其进行特殊处理。
看一下汇编:
仍然是放到 eax 寄存器中返回的。
埋个伏笔:你觉不觉的这个临时变量创建的很冤枉,明明这块空间一直存在,我却依然创建临时变量返回了?能不能帮它洗刷冤屈。
我们继续看
int& add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int ret = add(1, 2);
cout << ret << endl;
return 0;
}
如果我改成引用返回会发生什么情况吗?
引用返回就是不生成临时变量,直接返回 c 的引用。而这里产生的问题就是 非法访问 。
造成的问题:
- 存在非法访问,因为 add 的返回值是 c 的引用,所以 add 栈帧销毁后,会访问 c 位置空间,而这是读操作,不一定检查出来,但是本质是错的。
- 如果 add 函数栈帧销毁,空间被清理,那么取 c 值时取到的就是随机值,取决于编译器的决策。
ps:虽然vs销毁栈帧没有清理空间数据,但是会二次覆盖
例如:
例如这里,当调用 add 函数之后,返回 c 的引用,接收返回值是用的ret相当于是 c 的引用,这时由于没有清理栈帧数据,所以打印3;
但是第二次调用,重新建立栈帧,由于栈帧大小相同,第二次建立栈帧可能还是在原位置,之前空间的数据被覆盖,继续运算,但是此时,ret 那块空间的值就被修改了,而这时没有接收返回值,但是原先的那块 c 的值被修改,所以打印出来 ret 是 30 。
所以使用引用返回时,一旦返回后,返回值的空间被修改,那么都可能会造成错误,使用要小心!
引用返回有一个原则:如果函数返回时,出了函数作用域,如果返回对象还在(还没还给系统),则可以使用引用返回,如果已经还给系统了,则必须使用传值返回。
它俩的区别就是一个生成拷贝,一个不生成拷贝。
对于刚才那个委屈的static修饰的静态变量,我们就可以使用引用返回了
int& c()
{
static int n = 0;
n++;
return n;
}
因为 static 修饰的变量在静态区,出了作用域也存在,这时就可以引用返回。
我们可以理解引用返回也有一个返回值,但是这个返回值的类型是int&
,中间并不产生拷贝,因为返回的是别名。这就相当于返回的就是它本身。
有时引用返回可以发挥出意想不到的结果:
#include <cassert>
#define N 10
typedef struct Array
{
int a[N];
int size;
}AY;
int& PostAt(AY& ay, int i)
{
assert(i < N);
return ay.a[i];
}
int main()
{
AY ay;
// 修改返回值
for (int i = 0; i < N; i++)
{
PostAt(ay, i) = i * 3;
}
for (int i = 0; i < N; i++)
{
cout << PostAt(ay, i) << ' ';
}
return 0;
}
由于PostAt 的形参 ay 为 main 中 局部变量 ay的别名,所以 ay 一直存在;这时可以使用引用返回。
引用返回 减少了值拷贝 ,不比将其拷贝到临时变量中返回;并且由于是引用返回,我们也可以 修改返回对象 。
总结提炼:如果出了作用域,返回变量(静态,全局,上一层栈帧,malloc等)仍然存在,则可以使用引用返回。
6.4 效率对比
值和引用的作为返回值类型的性能比较:
#include <time.h>
struct A { int a[10000]; };
A a;
// 值返回
A TestFunc1() { return a; } // 拷贝
// 引用返回
A& TestFunc2() { return a; } // 不拷贝
void TestReturnByRefOrValue()
{
// 以值作为函数的返回值类型
size_t begin1 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc1();
size_t end1 = clock();
// 以引用作为函数的返回值类型
size_t begin2 = clock();
for (size_t i = 0; i < 100000; ++i)
TestFunc2();
size_t end2 = clock();
// 计算两个函数运算完成之后的时间
cout << "TestFunc1 time:" << end1 - begin1 << endl;
cout << "TestFunc2 time:" << end2 - begin2 << endl;
}
int main()
{
TestReturnByRefOrValue();
return 0;
}
由于传值返回要拷贝,所以当拷贝量大,次数多时,比较耗费时间;而传引用返回就不会,因为返回的就是别名。
对于返回函数作用域还在的情况,引用返回优先。
引用传参和传值传参效率比较 :
#include <time.h>
struct A { int a[10000]; };
void TestFunc1(A a) {}
void TestFunc2(A& a) {}
void TestRefAndValue()
{
A a;
// 以值作为函数参数
size_t begin1 = clock();
for (size_t i = 0; i < 10000; ++i)
TestFunc1(a);
size_t end1 = clock();
// 以引用作为函数参数
size_t begin2 = clock();
for (size_t i = 0; i < 10000; ++i)
TestFunc2(a);
size_t end2 = clock();
// 分别计算两个函数运行结束后的时间
cout << "TestFunc1(A)-time:" << end1 - begin1 << endl;
cout << "TestFunc2(A&)-time:" << end2 - begin2 << endl;
}
int main()
{
TestRefAndValue();
}
还是引用快,因为引用减少拷贝次数:
总结:引用的作用主要体现在传参和传返回值
- 引用传参和传返回值,在有些场景下可以提高性能(大对象 and 深拷贝对象 – 之后会讲)。
- 引用传参和传返回值,在对于输出型参数和输出型返回值很舒服。说人话就是形参改变,实参也改变 or 返回对象(返回值改变)。
6.5 常引用
- const 修饰的是常变量,不可修改。
- 临时变量具有常性,不可修改
a 本身都不能修改,b 为 a 的引用,那么 b 也不可以修改,这样就没意义了。a 是只读,但是引用 b 具有 可读可写 的权利,该情况为 权限放大 ,所以错误了。
这时,只要加 const 修饰 b ,让 b 的权限也只有只读,使得 权限不变 ,就没问题了:
而如果原先变量可读可写,但是别名用 const 修饰,也是可以的,这种情况为 权限缩小 :
对于函数的返回值来说,也不能权限放大,例如:
int c()
{
static int n = 0;
n++;
return n;
}
int main()
{
int& ret = c(); // error
return 0;
}
这样也是不行的,因为返回方式为 传值返回 ,返回的是临时变量,具有 常性 ,是不可改的;而引用放大了权限,所以是错误的;这时加 const 修饰就没问题:const int& ret = c()
临时变量具有常性:
那么这种情况为什么不可以?
而这样就可以了?
因为类型转换会产生临时变量 :
- 对于类型转换来说,在转换的过程中会产生一个个临时变量,例如
double d = i
,把i
转换后的值放到临时变量中,把临时变量给接收的值d
而临时变量具有常性,不可修改,引用就加了写权限,就错了,因为 权限被放大了 。
提炼:对于引用,引用后的变量所具权限可以缩小或不变,但是不能放大(指针也适用这个说法)
作用 :
在一些场景下,假设 x 是一个大对象,或者是深拷贝对象,那一般都会用引用传参,减少拷贝,如果函数中不改变 x ,尽量用 const 引用传参。
这样可以防止 x 被修改 ,而对于 const int& x 也可以接受权限对等或缩小的对象,甚至为常量:
结论 :
- const type& 可以接收各种类型的对象(变量、常量、隐式转换)。对于输出型参数用引用,否则用 const type&,更加安全
6.6 引用和指针的区别
从语法概念上来说,引用是没有开辟空间的,而指针是开辟了空间的,但是从底层实现上来说,其实是一样的:
int main()
{
int a = 10;
int& ra = a;
ra = 20;
int* pa = &a;
*pa = 20;
return 0;
}
汇编:
lea 是取地址:我们发现无论引用和指针,都会取地址,且这些过程和指针一样。
其实从汇编上,引用其实是开空间的,并且实现方式和指针一样,引用其实也是用指针实现的。
引用和指针的区别汇总:
- 引用概念上定义一个变量的别名,指针存储一个变量地址。
- 引用在定义时必须初始化,指针没有要求
- 引用在初始化时引用一个实体后,就不能再引用其他实体,而指针可以在任何时候指向任何一个同类型实体
- 没有NULL引用,但有NULL指针
- 在sizeof中含义不同:引用结果为引用类型的大小,但指针始终是地址空间所占字节个数(32位平台下占4个字节)
- 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
- 有多级指针,但是没有多级引用
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
- 引用比指针使用起来相对更安全
7 、内联函数
7.1 为什么要有内联函数
调用函数需要建立栈帧,栈帧中要保存寄存器,结束后就要恢复,这其中都是有 消耗 的:
int add(int x, int y)
{
int ret = x + y;
return ret;
}
int main()
{
add(1, 2);
add(1, 2);
add(1, 2);
add(1, 2);
add(1, 2);
return 0;
}
而针对 频繁调用 的 小函数,可以用 宏 优化,因为宏是在预处理阶段完成替换的,并没有执行时的开销,并且因为代码量小,也不会造成代码堆积。
例如,代码就可以写成这样
#define ADD(x, y) ((x)+(y))
int main()
{
cout << ADD(1, 2) << endl;
return 0;
}
但是宏也有缺点:宏的本质是一种暴力替换
- 不能调试(因为预编译阶段进行了替换)
- 没有类型安全的检查
- 有些场景下非常复杂
就拿 add
函数来说,可能一不小心就会写成 #define ADD(x + y) x + y
的样子;所以写宏时出错,要么是替换出错,要么是因为优先级出错,所以宏并不友好。
而 C++ 针对为了减少函数调用开销,又可以在一定程度上替代宏,避免宏的出错,从而设计出了内联函数。
内联函数的关键字为 inline
:
//inline + 正常命名的函数
inline int add(int x, int y)
{
return x + y;
}
int main()
{
int ret = add(1, 2);
cout << ret << endl;
return 0;
}
7.2 内联函数的概念
以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率
如果在上述函数前增加inline关键字将其改成内联函数,在编译期间编译器会用函数体替换函数的调用。
查看方式:
- 在
release
模式下,查看编译器生成的汇编代码中是否存在call Add
- 在
debug
模式下,需要对编译器进行设置,否则不会展开(因为debug
模式下,编译器默认不会对代码进行优化)
打开解决方案资源管理器,右击项目名称,选中属性并打开,在 C/C++ 区域常规部分,在调试信息一栏设置格式为程序数据库:
在 C/C++ 优化一栏,将内联函数扩展部分选中只适用于 _inline :
设置完毕后,点击应用。
在设置前、后,分别启动调试,查看反汇编代码:
修改前:
修改后:
两段反汇编代码最大的区别就是 call 消失了 ,call 就是函数调用的指令,它的消失就说明第二段代码没有进行调用。内联函数直接在局部展开了,在 main 函数中完成了操作。有了内联,我们就不需要去用 c 的宏了,因为宏很容易出错。
7.3 内联函数的特性
-
内联函数是一种时间换空间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用
缺陷:可能会使目标文件变大,
优势:少了调用开销,提高程序运行效率。 -
inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。下图为《C++prime》第五版关于inline的建议:
-
inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址了,链接就会找不到。
特点讲解:
🐺1)空间换时间是因为反复调用内联函数,导致编译出来的可执行程序变大,这个我们看上面的汇编代码也能看出来,直接用函数体(函数里面的内容)替换函数调用了
inline void func()
{
// 假设编译完成为 10 条指令
}
若不用内联函数,不展开,若1000次调用 func,每次调用的地方为 call 指令的形式,总计 1010 行指令。若用内联函数,则展开,若一千次调用,每次调用的地方为都会展开为 10 条指令,总计 10 * 1000 行指令。
展开会让编译后的程序变大,如果递归函数作内联,后果可想而知。所以长函数和递归函数不适合展开。
🐺 2)编译器可以忽略内联请求,内联函数被忽略的界限没有被规定,一般10行以上就被认为是长函数,当然不同的编译器不同
基于上面的解释,所以编译器会决策是否使用内联函数。因为如果函数太大导致的结果很糟糕。
🐺3)内联函数声明和定义不可分离
// F.h
#include <iostream>
using namespace std;
inline void f(int i);
// F.cpp
#include "F.h"
void f(int i)
{
cout << i << endl;
}
// main.cpp
#include "F.h"
int main()
{
f(10);
return 0;
}
// 链接错误:main.obj : error LNK2019: 无法解析的外部符号 "void __cdecl
f(int)" (?f@@YAXH@Z),该符号在函数 _main 中被引用
由于内联函数在调用的地方展开,所以内联函数无地址(这里的地址指的是call 指令调用函数的地址,通过这个地址会跳到 jmp 指令处,再根据 jmp 处指令跳转到函数执行的部分) ,即 f.cpp->f.o 符号表中,不会生成 f 的地址。
当编译时,由于头文件要被包含,但是这时只有函数声明,但是没有函数的定义,所以只能在链接时展开,这里只能变为 call + 地址的指令,但是内联函数并没有地址,链接不到,就报错了。
所以当声明和定义分离,调用函数时,由于内联函数无地址,编译器链接不到,所以就会报错,为链接错误。
结论:简短,频繁调用的小函数建议定义成 inline .
8 、auto(C++11)
8.1 auto的概念
auto是作为一个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得
注意:使用auto定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto的实际类型。因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,**编译器在编译期会将auto替换为变量实际的类型。**
8.2 auto的使用
- auto与指针和引用结合起来使用
用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时则必须加&
- 在同一行定义多个变量
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错,因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量
void TestAuto()
{
auto a = 1, b = 2;
auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}
8.3 auto不能使用的场景
- auto不能作为函数的参数
// 此处代码编译失败,auto不能作为形参类型,因为编译器无法对a的实际类型进行推导
void TestAuto(auto a)
{}
- auto不能直接用来声明数组
void TestAuto()
{
int a[] = {1,2,3};
auto b[] = {4,5,6};
}
- auto不能独立定义
int main()
{
int a = 0;
auto b; //会报错
}
9 、范围for循环(C++11)
9.1 基本使用
之前对于数组的遍历,需要使用下标遍历:
int main()
{
int arr[] = { 1, 2, 3, 4,5 };
for (int i = 0; i < sizeof(arr) / sizeof(arr[0]); i++)
{
printf("%d ", arr[i]);
}
return 0;
}
使用范围for
int main()
{
int arr[] = { 1, 2, 3, 4,5 };
for (auto num : arr)
{
cout << num << endl;
}
return 0;
}
for循环后的括号由冒号“ :”分为两部分:
第一部分是范围内用于迭代的变量,
第二部分则表示被迭代的范围
可以看到我们不需要计算数组大小,并且我们直接使用auto识别类型,直接起飞,当复杂类型遇到auto和范围遍历,就是天堂。
原理:范围for
循环的原理就是自动取遍历目标的每一个元素,再放到给定的临时变量中。在上方就是取 arr
的元素放到num
中,并自动判断结束。auto
会根据遍历目标的元素类型自动推导,当然直接写类型 int
也对
如果修改数组内容我们可以使用引用
9.2 使用条件
for循环迭代的范围必须是确定的
对于数组的范围就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的方法,begin和end就是for循环迭代的范围。
以下代码就有问题,因为for的范围不确定,因为函数传参,数组就会退化为指针:
void TestFor(int array[])
{
for (auto& e : array)
{
cout << e << endl;
}
}
10 、指针空值nullptr(C++11)
对于 c 来说,空指针为 NULL,是一个宏。
在 C++98/03 时,只能使用 NULL ;而 C++11 后,推荐使用 nullptr 。
在c++中NULL定义 :
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
实际上 NULL 就是个宏,所以说写成 int* p = 0 ,也可以;而j绝大多数情况下,这样写都没问题。
但是对于极端场景:
void f(int) // 这边由于不使用形参,不给形参名也可以
{
cout << "f(int)" << endl;
}
void f(int*)
{
cout << "f(int*)" << endl;
}
int main()
{
f(0);
f(NULL);
return 0;
}
按道理,对于第一次调用,应该匹配第一个,对于第二次调用,应该匹配第二个。
但是实际上它们都匹配了第一个,原因是 NULL 是一个宏,本质为 0
在C++98中,字面常量 0 既可以是一个整形数字,也可以是无类型的指针(void* )常量,但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void* )0,例如:(int*)NULL
,所以在 C++11 后,使用 nullptr 是明智的选择。
注意点:
- 在使用
nullptr
表示指针空值时,不需要包含头文件,因为nullptr
是C++11作为新关键字引入的。 - 在C++11中,
sizeof(nullptr)
与sizeof((void*)0)
所占的字节数相同。 - 为了提高代码的健壮性,在后续表示指针空值时建议最好使用
nullptr
。