C++学习记录(1)C++入门
前言:学习过C语言和数据结构,已经具备了一定的代码能力,基于此深入进行面向对象的编程语言的学习,即C++的学习。
一、第一个C++程序
相信大家第一次接触C语言的时候,第一个程序写的一定是:
#include <stdio.h>
int main()
{
printf("hello world!");
return 0;
}
学习C++我们也从这里开始:
#include <iostream>
using namespace std;
int main()
{
cout << "hello world!" << endl;
return 0;
}
逐行开始分析,首先我们C++输入输出包的文件是iostream,实际上为标准输入输出流的缩写,而输入输出流在C语言阶段就作以了解了。
剩下的只有两行代码对我们来说是不知所云,即:
using namespace std;
cout << "hello world!" << endl;
下面我们作以详细讲解。
二、namespace命名空间
1.namespace的定义
namespace我们叫做命名空间,这是一个关键字,语法格式类似于结构体:
namespace name(自己取一个名字)
{
定义变量 函数 类等
}
2.为什么要有namespace
C++是C语言的再发展,namespace的出现就解决了C语言中重定义的问题,什么叫重定义的问题呢?
我们知道stdio.h中我们最常用的函数就是printf和scanf等,一般不会有人用其作为变量名,所以可能不会因为printf和scanf出现重定义的问题,但如果是这样的情况呢:


左边的图片最后是可以正常输出rand的值的,但是右边的图片由于stdlib.h中声明了一个rand函数,用来根据种子生成伪随机数,那么这时就会产生重定义的问题:

同样的,在日常编程开发中,往往是团队中几个人共同开发,而并不是单个的人开发,所以到链接的过程中就有可能会造成重定义的问题,因此就引入了namespace。
namespace就是来解决这样的问题的:

如何理解这个命名空间呢?
在C语言阶段我们一般最关心的域就是变量的作用域,而作用域一般我们就分为全局域和局部域,例如C语言中我们经常会讨论的一个问题:

一个变量在全局域和局部域均有定义,就近使用,也可以说是“强龙压不过地头蛇”。
当时就说这两个域所以可知先后顺序是先局部后全局。
有没有什么办法能够绕过局部直接找到全局呢?
用:: 也被叫做作用域解析运算符,可以查找特定作用域中的成员(可以是变量 函数等)
一般用法 space::member
即左操作数可以填域名(一般在C++中我们认为域分为全局域 局部域 命名空间域 类域),右操作数填该域中的成员。
如果左操作数不填默认为全局域 如:

回到最开始的问题,有了namespace来限制rand以后我们就可以做到:

首先,rand在stdlib.h中是一个函数,而函数名就类似于数组名,都是一个指针,指针打印我们就要用%p,而rand函数在stdlib.h中就会在预处理阶段展开,一旦展开我们非常清楚,rand函数可以说就暴露在了全局域中,所以查找的话默认查找和全局域查找都只能找到stdlib.h中的rand,因rand(变量)被我们用命名空间space包裹了起来,除非用操作符强制限定才能被访问到。
所以namespace的基本用法就是这样,
在默认查找(查找的是声明或者定义)的情况下不声明的情况下先局部再全局,如果使用操作符但是并没有限定命名空间,那么久全局找,如果直接限定死命名空间,那就直接在命名空间内找即可。如果找不到编译器就会自动报错。
也内含变量 函数等来防止“泄漏”导致冲突,如:
namespace space
{
int rand = 0;int Add(int x, int y)
{
return x + y;
}typedef struct SLTNode
{
int data;
struct SLTNode* next;
}SLTNode;
}
3.补充说明
除了上面提到的namespace的定义 意义以外,还有:
①namespace只能定义在全局,但namespace可以嵌套,即namespace中可以定义namespace。
②我们目前了解了C++中有四个域,全局域 局部域 命名空间域 类域。特别强调,命名空间域和类域只是用来隔离,防止名字重复造成冲突,并不会影响变量的生命周期,对于命名空间里成员来说,除了被隔离起来,其实仍然是全局的生命周期。
③项目工程中多文件定义的同名的namespace将会认为一个namespace。
④C++的标准库放在命名空间std(stadard中)。
4.命名空间的使用
在命名空间性质补充说明中我们已经提到了,如果不特殊声明还想要查找一个变量或其他什么,只会在全局域和局部域进行查找,所以如果想要使用命名空间的成员,需要特别声明。
- 指定命名空间访问,在大项目中常用
如,我们上面见过的

- 展开命名空间的所有成员,项目当然不推荐这样的方式,避免冲突
我们一开始的C++的hello world用的就是这样的方式:

利用using关键字指明哪个命名空间需要展开,好像namespace包裹着std,而后利用using去戳破这层膜。
- using展开命名空间某个成员,项目中常常用此展开常用的且不存在冲突的成员
我们在上面其实也举过例子,可能会有人想用rand作为变量名,但是不会有人用printf和scanf作为变量名,同样道理,在C++中我们常用的输入输出,即cout和cin,也不会有人用此做变量名等,所以往往可以释放出来:

endl并没有释放,所以仍需注明命名空间。
三、C++输入&&输出
基于C语言printf和scanf函数的理解下,类比学习有以下要点:
1.包含的头文件为<iostream>InputOutputStream的缩写,标准的输入流、输出流库的缩写,定义了标准输入输出的对象。
2.std::cin 是istream类的对象,它主要面向窄字符(narrow characters(of type char))的标准输入流。
3.std::cout 是ostream类的对象,它主要面向窄字符的标准输出流。
(2 3就有点抽象了毕竟现在没学过什么是类,什么是对象,而且窄字符也不知道是什么,不过不要紧,毕竟当时用printf可比学printf要早)
4.std::endl 是⼀个函数,流插入输出时,相当于插入⼀个换行字符加刷新缓冲区。(其实这个玩意也是有点抽象,毕竟我们看看endl咋看咋像关键字,但是没想到其实他也是一个函数。)
5.<<被叫做流插入运算符,被用于输出C语言阶段我们已经了解了,如果想输出,那么我们应该先让数据入流,然后才能输出到屏幕、文件等;>>被叫做流提取运算符,被用于输入,同样道理,如果想从键盘、文件等输入,那么应该先入流。这两个符号可以说是非常形象了。
6.为什么C++要用看起来比printf和scanf更加麻烦的方式来输入输出呢?
使用C++输入输出更方便,不需要像printf/scanf输入输出那样手动指定格式,C++的输入输出可以自动识别变量类型(本质是通过函数重载实现的),其实最重要的是C++的流能更好的支持自定义类型对象的输入输出。(这点可以说复制粘贴来的,反正知道牛逼就用吧,以后学到用到就能理解了)
示例:

可以看到输入不多解释,输出的时候可以一行一行输出,也可以类似于我们超市里排队算账一样,需要换行就换行(C++换行常用endl,当然也可以用\n来替换,两者还是有细微区别,以后会学到)可以说最下面的换行一次输出四个对象。
由第一个C++程序我们引出了命名空间和C++的输入输出,但是我们正式开始C++的学习之前还有关于几个点需要了解一下。
四、缺省参数
1.缺省参数的定义
缺省参数是在函数声明或者定义时,给予形参默认值,如果调用该函数时,没有指定实参则采用给予的形参的默认值,否则使用指定的实参,缺省参数分为全缺省和半缺省。
简单例子:

C++的函数可以给缺省参数(或者说默认参数),如果传参不给,就用给予的默认值,给了就用指定的,完全符合定义。
也可以将函数参数写的更多一点:
2.全缺省参数

这种情况是全缺省参数,也就是所有的参数都是具有默认值的,全缺省参数的话如果要传值必须从左往右传,绝对不能间隔。

比如这种臆想的我中间给你空下来了,编译器可不知道你啥意思,他还以为你多给了一个逗号。
3.半缺省参数
半缺省参数就是部分形参赋予默认值,而且语法规定,缺省值只能从右往左给。
避免这样的歧义:

观察到Func函数的形参z是没有默认值的,那么这样很明显按顺序你2就得传给z,但是你这么解释的话,那么请问1是给谁的呢?而且万一我就是想给x y分别传1,2忘了给z传呢?所以如果半缺省参数,那么默认值应该从右往左。
4.缺省参数的小应用
#include <assert.h>
typedef int STDataType;
typedef struct Stack
{
STDataType* arr;
int top;
int capacity;
}ST;void STInit(ST* p)
{
assert(p);p->arr = NULL;
p->capacity = p->top = 0;
}void CheckCapacity(ST* p)
{
if (p->capacity == p->top)
{
int newcapacity = p->capacity == 0 ? 4 : (p->capacity) * 2;
STDataType* temp =(STDataType*)realloc(p->arr, newcapacity*sizeof(STDataType));
if (temp == NULL)
{
perror("realloc fail!");
exit(1);
}
p->arr = temp;
p->capacity = newcapacity;
}
}
void STPush(ST* p,STDataType x)
{
assert(p);CheckCapacity(p);
p->arr[p->top++] = x;
}int main()
{
ST st1;
ST st2;
ST st3;
STInit(&st1);
STInit(&st2);
STInit(&st3);//明确确定要插入一百个数据
for (int i = 0; i < 100; i++)
{
STPush(&st1, i);
}//并不确定要插入多少个数据
STPush(&st2, 2);
for (int i = 0; i < 4; i++)
{
STPush(&st3, 2);
}return 0;
}
上面是我们在C语言阶段学习数据结构很常见的一个栈的实现以及栈的插入,下面有两个场景,一个是固定就要插入100个数据,另外的其实并不确定要插入多少个数据,有的可能就插入一个也可能更多。
而对于插入数据固定的栈,其实我们可以在初始化的时候直接就申请一百个数据的空间,即修改初始化函数为:
void STInit(ST* p,int n = 4)
{
assert(p && n > 0);STDataType* temp = (STDataType*)malloc(n*sizeof(STDataType));
if (temp == NULL)
{
perror("malloc fail!");
exit(1);
}
p->arr = temp;
p->capacity = n;
p->top = 0;
}
当然,这么做的话,CheckCapacity就得优化一下,毕竟第一次内存不够一定是top到达4,此时可以直接扩容到8,剩下都是二倍扩容,这样可以省一次函数栈帧的开辟,不过无伤大雅,在这里就不写了。
重点还是回到这个Init函数,很明显,一个缺省参数,就可以解决固定大小的问题,毕竟你如果刚好要100个数据空间,可以给出来,如果不给出来,那我自己给你初始化4个数据的大小。
强调:我们在这种栈的实现和应用时,常常要将函数的实现和声明分开到头文件和实现文件中,此时规定缺省参数不能在函数声明和定义中同时出现,规定在必须函数声明给缺省值。
五、函数重载
C++支持我们在同一作用域中出现同名函数,但是要求这些同名函数形参必须不同,可以是参数的个数不同,也可以是参数的类型不同。这样C++函数就出现了多态行为,使用更加灵活,而此特性在C语言中是不支持的。
这种行为怎么理解呢?
比如很经典的一个Add函数:


上面四个Add函数只是形参的类型不同,个数不同不再举例,如果这样来的话,我们调用Add函数就涵盖了多种情况,如果写的再全面一点,不论怎么传参都可完成任务。
借助Add函数,见识到了函数重载的冰山一角,如果在C语言阶段我们想要做到的话可能就得AddDouble,AddInt了。
不过也有冲突的地方:
从定义上来看,同一作用域,同名,参数个数或者类型不同完全符合,但是调用的时候无参函数可以不传参调用,缺省函数可以无参调用,如果这个时候强行调用,编译器直接凌乱,干脆不允许你程序运行。
六、引用
上来看见个引用,给人整懵b了,其实类比一下很简单,齐天大圣一说起来大家都知道是那个猴头,菩提祖师给他取名孙悟空,天庭神仙都叫弼马温,八戒沙僧叫他大师兄(有时候八戒也叫什么猴哥)有的妖怪叫起来叫什么孙猴子、猴头,碰见金角银角的大葫芦几次逃脱还见过什么孙行者、者行孙、行者孙。
这么多名字和绰号只有孙悟空才是他的大名,其他都是不同身份不同情景下取得外号罢了,但是一说起来我们不都知道指的是孙悟空嘛。
1.引用的定义
C++里的引用也是如此,引用不是新定义了一个变量,而是给已存在的变量起了一个别名,编译器不会为引用变量开辟内存空间,它和它引用的变量共用一个内存空间。
类似于:

语法格式:变量类型&别名 = 引用对象;
例如:
int main()
{
int a = 10;
int& b = a;
int& c = a;
int& d = b;cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl;
cout << "d = " << d << endl;return 0;
}
输出结果是:

a是创建的变量,初始化为10,b和c是a的别名,输出b和c相当于输出a,d是b的别名,而b是a的别名,其实相当于d是a的别名,所以其实上面的代码相当于把a打印了四次。
验证:
int main()
{
int a = 10;
int& b = a;
int& c = a;
int& d = b;cout << &a << endl;
cout << &b << endl;
cout << &c << endl;
cout << &d << endl;return 0;
}


2.引用的性质
- 引用在定义时必须初始化
- 一个变量可以有多个引用
- 一个引用一旦初始化,就不能再修改引用的对象
最后一点可以作以解释,看这样一段代码:
int main()
{
int a = 10;
int& b = a;
int& c = a;
int& d = b;cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl;
cout << "d = " << d << endl;cout << &a << endl;
cout << &b << endl;
cout << &c << endl;
cout << &d << endl;int x = 20;
c = x;cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl;
cout << "d = " << d << endl;cout << &a << endl;
cout << &b << endl;
cout << &c << endl;
cout << &d << endl;
return 0;
}

可见引用并没有改变指向,程序给a申请的内存空间仍旧是那个内存空间,并没有变成x的内存空间,只不过给c赋值了,而且由于c是a的引用,相当于把abcd的值都改成了20。
3.引用的使用
①引用通常来代替指针传参,更加好理解和使用
经典使用指针的场景,Swap函数:
void Swap(int* px, int* py)
{
int temp = *px;
*px = *py;
*py = temp;
}
在C语言学习阶段我们这么写代码是因为传参分为传址调用和传值调用,如果是int x,int y的话,仅仅就是对参数的临时拷贝,临时拷贝的交换一旦出Swap函数就会荡然无存,所以必须传址调用,直接顺着指针对所要交换的变量入手。
在学习了引用以后可以将Swap函数的形参变为引用,这样的话就可以不借助指针进行交换。
void Swap(int& x, int& y)
{
int temp = x;
x = y;
y = temp;
}
原理是什么呢?
还是说定义,引用相当于给已存在的变量取了一个别名,这样的话,传过去的变量可以说就是它本身,这样的话就类似于指针的作用,直接针对实参进行操作。
这下有两个优点:
- 很明显引用作函数形参,修改形参可以影响实参
- 引用作函数形参,减少拷贝,提高效率:在之前我们学习数据结构的时候,就算能传结构体我们也要传指针,就是因为指针它只跟操作系统有关,32位指针就占4个字节,64位就占8个字节,而如果用引用作形参,直接不拷贝了,取别名相当于用本身也不申请新的内存空间。
当然,千万不要晕,比如这样一个场景:
指针变量也是变量,我们也可以给指针变量取别名:
int main()
{
int x = 1;
int y = 0;
int* px = &x;
int* py = &y;
//int*& rpx = px;
//int*& rpy = py;return 0;
}
道理很简单,你严格按照语法格式:变量类型&别名 = 引用对象;
你指针变量也是变量,无非你的类型需要带上*,比如这里的int*。
当然,仅仅空给指针取个别名有什么用呢,肯定是没用的,但是如果我们碰到了要交换两个指针变量呢?
要是以前我们肯定就要上二级指针:
void Swap(int** ppx, int** ppy)
{
int* temp = *ppx;
*ppx == *ppy;
*ppy = temp;
}
但是现在我们学了引用,就可以把引用当作形参:
void Swap(int*& rpx, int*& rpy)
{
int* temp = rpx;
rpx = rpy;
rpy = temp;
}
关系就是:

②传值返回与传引用返回
在正常情况下我们很容易写出这样的函数:
int Func()
{
int ret = 0;//....
return ret;
}int x = Func();
我们写出来一个函数经历一系列设计最后得到返回值,我们知道这里的ret其实就是一个临时变量,出了Func函数就直接销毁了,在C语言阶段很少提,其实肯定不能说返回的是ret,因为ret已经销毁,返回的其实是ret的临时拷贝,等到被接收以后自然就销毁。
再来看这样一个场景:
typedef int SLDataType;
typedef struct SeqList
{
SLDataType* arr;
int size;
int capacity;
}SL;
void SLInit(SL&sl)
{
sl.arr = NULL;
sl.capacity = sl.size = 0;
}void CheckCapacity(SL& sl)
{
if (sl.capacity == sl.size)
{
int newcapaciy = sl.capacity == 0 ? 4 : sl.capacity * 2;
SLDataType* temp = (SLDataType*)realloc(sl.arr, newcapaciy*sizeof(SLDataType));
if (temp == NULL)
{
perror("realloc fail!");
exit(1);
}
sl.arr = temp;
sl.capacity = newcapaciy;
}
}void SLPushBack(SL& sl,int x)
{
CheckCapacity(sl);sl.arr[sl.size++] = x;
}int SLAt(SL& sl,int num)
{
assert(num < sl.size);return sl.arr[num];
}int main()
{
SL sl;SLInit(sl);
SLPushBack(sl, 1);
SLPushBack(sl, 2);
SLPushBack(sl, 3);
SLPushBack(sl, 4);for (int i = 0; i < sl.size; i++)
{
cout<<SLAt(sl, i)<<endl;
}return 0;
}
简单来说就是顺序表里有四个元素,1234,现在我们挨个给他输出,这个倒是没有什么疑问。
但是如果要给我们每次通过SLAt(sl.i)得到的值进行修改呢,因为总有场景并不是仅仅得到顺序表某个下标的元素的值,而是要对其进行修改。
但是一旦这么做就会得到:

直接告诉你表达式左值不能修改。
其实这就是我们的一开始那个Func函数。


C++规定,临时拷贝具有常性,什么叫具有常性呢?类似于被const修饰一样,你读取她肯定没啥问题,但是如果你一旦想要修改,那肯定就是不被允许的。
因此,如果直接对这个临时拷贝进行+=1,肯定会弹左值必须得能修改。
如果想要打印结果避免,直接用个临时变量接收就行:

但是我们的目的却是直接修改顺序表中的元素,这样的话无异于掩耳盗铃,毫无作用,当然也可写个change函数,专门去传值再修改。
但是我们有引用,不妨这样尝试:

如果这样的话,相当于:

临时拷贝变成了引用,这样的话相当于传回去的返回值就是指定元素本身,如果对此进行操作,肯定就可以修改到那个元素本身,所以:

传引用返回可以对返回值进行直接修改,而且可以影响返回值本身。
特别注意:
我们这里是动态申请的内存空间,所以呢这里的值一直都在堆区存放,因为我们也并没有对他进行销毁,这里你通过引用直接访问也无可厚非。
但是如果是这样呢:
int& Func()
{
int ret = 0;//....
return ret;
}int x = Func() + 1;
函数调用是在栈区进行的,函数栈帧一旦销毁,ret也顺带被销毁了,如果这个时候对ret的引用进行修改,相当于要修改ret原来的那块内存空间,被释放的空间还要继续访问,那岂不是类似于野指针了嘛,可以说是野引用,这种行为是不安全的,要尽量避免。
4.const引用
我们之前见得引用是这样的:
int x = 0;
int& r1 = x;
如果被const修饰的变量能直接引用吗?
const int y = 1;
int& r2 = y;

很明显是不可以的。
这个问题其实也好说,x可读可修改你给个引用肯定也是可读可修改,而y只能读不支持修改,但是你给出来的意思是什么,引用y以后可读可修改,这岂不是权限的放大吗?那就是不支持的,除非:
const int y = 1;
const int& r2 = y;
权限的平移没啥问题,权限放大不被允许,剩个权限缩小:

其实逻辑上想想也没啥毛病,权限平移可以,权限缩小可以,权限放大不允许。
除此之外,还有一些隐含的需要const修饰的引用,如:
常量、表达式:
const int& r3 = 10;
const int& r4 = x * 10;
还有个比较特别的隐式格式转换:
double d = 3.14;
const int& r5 = d;
这是什么情况呢?
在C++中格式转换并不是直接转换的,而是先创建个临时对象(所谓临时对象就是编译器需要⼀个空间暂存表达式的求值结果时临时创建的⼀个未命名的对象, C++中把这个未命名对象叫做临时对象)存储下double到int的值,然后再给引用,这样的话创造出的临时对象其实还是具有常性的,于是也就仍然不能修改,必须被const修饰,即:

一定要深入理解这个临时拷贝:
也就是说r5实际上是临时拷贝的别名,而不是d的别名,因为转换有中间商,真的就好像产地-中间商-销地一样,产地和销地必须有中间商联系,但是由于销地只绑定中间商,d就好像临时产地一样,中间商收货渠道不止一个,肯定不会影响某个销地r5的散货量。
5.指针和引用的关系
其实指针和引用就像同一个专业里的好兄弟一样,两者学习的东西是一样的,对于用人单位来说功能其实是大同小异的,一般来说,功能是具有重叠性的,实际应用上应该是相辅相成的,但是各有各特点,其实是无法互相替代的。
对比:
- 语法上引用是一个变量的别名,不另外开辟内存空;指针实际上是一个变量,开辟内存空间存放地址。
- 引用定义时必须初始化,即指明引用的对象;指针建议初始化(用NULL栓住指针这条脱缰的野狗),但是其实语法上不是必须的。
- 引用只能指明一个引用的对象,一旦确定以后不可修改;指针存放的地址可以进行修改。
- 引用可以直接访问指向的对象;指针必须解引用才能使用。
- sizeof对于引用来说最终结果是引用对象的类型大小;sizeof对于指针来说其实只看操作系统(32位就4个字节,64位就8个字节)。
- 引用一般很少出现野引用的问题相较于指针的空指针解引用了、野指针等更加安全。
当然,其实底层来说引用其实就是以指针来实现的,例如如果r1是x的引用的话,实际上是&r1=&x,这步其实还是开辟内存空间了,但是在语法定义上,其实还是说引用就是别名,不另外开辟内存。
七、inline
1.内联函数的定义和由来
inline是用来修饰函数的,被inline修饰的函数叫做内联函数,编译时C++编译器会在调用的地方直接展开,这样调用内联函数就无需再建立栈帧,可以提高效率。
我们在C语言阶段一定学过宏函数,宏函数就是内联函数的前身,比如我们学的最经典的宏函数ADD:
#define ADD(int a,int b) return a + b;
如果写出这种写法,那就是把宏函数与一般的函数混为一谈,宏的本质是替换,并不是函数的平铺直叙。
#define ADD(a,b) a + b;
写个这个乍一看没什么毛病,如果我们真的用来可能是这样:
int main()
{
int ret = ADD(1,2);//int ret = 1 + 2;;
return 0;
}
这样的话就会有一个问题,宏就是全替换,就会造成多了一个分号,但其实对于这段代码并不影响,因为多的分号可以被编译器认为是一个空语句。
但是如果放到布尔表达式里呢?

直接就是一个报错,这个可以说就是为什么宏不允许有分号。
#define ADD(a,b) a + b
这种写法分号是没了,但是又造成了优先级的问题:
int main()
{
int ret = ADD(1, 2) * 5;//int ret = 1 + 2 * 5;
return 0;
}
其实本意是想让1+2以后再*5,但是由于没有括号,直接替换就又变成了这种错误。
继续改:
#define ADD(a,b) (a + b)
以为这样就高枕无忧了,实际上还是会犯优先级的问题:
int main()
{
int x = 1;
int y = 2;
int ret = ADD(x & y, x | y);//int ret = x & y + x | y;
return 0;
}

根据优先级表,肯定是先加减再按位操作,但是我们写成这样的宏很明显目的是先按位操作再加起来,所以还得继续加括号:
#define ADD(a,b) ((a) + (b))
C++的设计者就是深受这种宏函数的荼毒,因此创造出了内联函数,不然一个简单的ADD函数就这样,如果还有比它复杂的宏函数该怎么样呢?
有的人可能这个时候会说,那直接不用宏函数不好了,宏函数复杂/类型不安全检查/不能调试,那还要这个干啥,但是宏函数最大的优点不就是在预处理阶段就会展开,相当于表达式,根本不需要执行的时候创建函数栈帧,提高代码效率啊,看着没啥用,如果一个项目有类似于ADD宏一样需要执行一万次呢,所以内联函数就是基于此优化而来。
2.如何实现
其实实现内联函数非常简单,直接把inline加到你要变内联函数最前面即可,如:
inline int Add(int x, int y)
{
return x + y;
}
不妨调试一下:

转到反汇编容易发现,其实并没有说跟宏函数一样直接展开,没有创建函数栈帧,实际上这个call指令就是说明创建了函数栈帧,现在要调用函数,继续下去就是:
call指令的下一条指令实际上就是跳转到理论要调用Add函数的指令,这句指令的07FF64...明显就是除了16进制后缀h以外的那句地址,而且这句指令以后应该就跳到了真正的函数栈帧:

为什么我们加了incline以后函数好像并没有变成内联函数呢?
VS编译器debug版本下面默认是不展开inline的,这样方便调试,debug版本想展开需要设置⼀下 以下两个地方。

改为


改为

由此再调试并转到反汇编:

可以发现没有一条指令再去call Add函数了,可以说明显展开了,没有创建函数栈帧。
当然,如果不想修改设置,搞个release也是可以的。
3.内联函数的注意事项
有的人可能就会想,既然内联函数可以避免函数栈帧的创建,那我干脆把所有的函数都加上inline不就行了。设计者早就想到了会有这么不靠谱的人出现,所以人家在设计编译器的时候就考虑到了,inline对于编译器仅仅是一个建议,也就是说,就算你有的函数加了inlline,编译器可能也不给它展开,仍旧调用函数栈帧,因为展开让文件变大了,这样不调用栈帧省下来的时间更加浪费到读取指令上了,得不偿失。
不同编译器对于多大的函数才会不理睬inlilne并没有被C++标准规定,知道有这个事就可以。
例如:
首先,这个是加了仅仅几句ret++而已,就让展开变的非常长,假如说这个函数在项目中经常被调用,就好像我们写文章总得写一大堆逗号和句号一样,每写几句都要带上,这样的话最终变成二进制的指令就会让计算机更加不堪重负。


又随便加了几句指令,直接又去创建函数栈帧了。
所以说,一般只有短小且频繁使用的函数变成内联函数才更加有价值,否则就算是你都加上inline人家编译器也对你不理不睬的。
4.inline函数不能声明和定义的分离
提到函数,难免就要进行声明和定义的分离,毕竟如果一个非常大的项目,基本上所有函数都得进行声明和定义的分离,但是凡事都有例外,内联函数不能进行声明和定义的分离。
简单解释一下:

现在写了两个函数,这两个函数现在都是普通函数,声明和定义分离,方便管理。

在当时是这样理解的:
程序最终要运行是要经过四个阶段,预处理 编译 汇编 链接。
而正常的函数声明放头文件里,定义放实现文件cpp里。C语言阶段见过,学习数据结构的时候更是每次都得分开。
但是如果是内联函数,需要直接把定义放到头文件里,千万不能分开。如果仍旧是这样:

直接告诉你说这个函数你没写我不认识,怎么能调用呢,这个时候又得说到编译和链接的问题了:

如果函数的声明和定义分开的情况下,预处理阶段会将头文件都展开,此时两个.i文件都有函数的声明,到链接阶段会分别在所有.o文件的符号符号表里去寻找前面头文件里只挂函数声明的函数的定义,如果没有的话就会报错。
但是内联函数不一样,内联函数要写到头文件里,在编译阶段展开。inline函数编译器可能作以两种处理,直接替换或者生成弱符号定义(弱符号定义是相比于正常函数的符号定义,正常函数声明定义分离会先在符号表中生成符号,链接阶段再处理)弱符号定义会使即使链接阶段的inline函数不管在多少个文件中都不会产生冲突,这个可以说是inline函数的特别之处。
原因是这样的,普通函数如果把定义放到头文件里,每个包这个头文件的cpp文件就都会在预处理阶段具有完全相同的函数,那么这样既不构成重载,又不是内联函数就会报错。
正常对于内联函数来说,应该是在预处理阶段由于头文件被展开而暴露出来,在编译阶段如果短小且精悍的函数就会直接在调用点展开;有递归、逻辑复杂或者语句非常多的函数编译器也可选择正常调用。
这样对比下来就是普通函数不能放到头文件里,因为如果放到头文件里,最后链接阶段有完全相同的两个函数直接就会产生冲突;但是内联函数必须放到头文件里,因为如果没放到头文件里,汇编阶段根本没办法展开。
八、nullptr
我们之前C语言阶段一直用的NULL实际上是一个宏:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void*)0)
#endif
#endif
在C语言头文件<stddef.h>可以看到这段代码。
可以看到C++里其实NULL就是0,我们一般C语言阶段碰见的就是0,只不过被强转成void*类型的。
这种情况在C++里容易被误用,如:
void f(int x)
{
cout << "f(int x)" << endl;
}
void f(int* ptr)
{
cout << "f(int* ptr)" << endl;
}
int main()
{
f(0);
f(NULL);
return 0;
}

本意我们是想用多态,0调用第一个函数,NULL当成空指针调用第二个函数,但是在定义中我们就已经知道NULL实际上就是0,所以只能调用第一个函数。
因此引入nullptr。
C++11中引⼊nullptr,nullptr是⼀个特殊的关键字,nullptr是⼀种特殊类型的字面量,它可以转换 成任意其他类型的指针类型。使用nullptr定义空指针可以避免类型转换的问题,因为nullptr只能被 隐式地转换为指针类型,而不能被转换为整数类型。

更多推荐
所有评论(0)