前言
我们上一期介绍了什么是C++,命名空间、输入输出、以及缺省参数。本期我们来继续介绍C++的入门知识!
本期内容介绍
函数重载
引用
内联函数
auto关键字
范围for
指针空值nullptr
目录
前言
本期内容介绍
一、函数重载
什么是函数重载?
C++支持函数重载的原理
二、引用
什么是引用?
引用的特性
常引用
再谈临时变量
引用的使用场景
1、做参数
2、做返回值
引用和传值的效率比较
传参和传引用比较
值返回和引用返回比较
引用和指针的区别
三、内联函数
什么是内联函数?
内联函数的特性
宏的优缺点
四、auto关键字(C++11)
auto关键字介绍
auto使用细节
五、范围for(C++11)
范围for语法
范围for的使用条件
六、指针空值nullptr(C++11)
一、函数重载
什么是函数重载?
函数重载:在同一作用域中,函数名相同、形参列表不同的函数。形参列表不同是指:参数的个数、类型、顺序不同! 函数重载对于函数的返回值没有要求!
OK,举个栗子:
void Add(int x, int y)
{cout << "haha" << endl;
}int Add(int a)
{return a + 10;
}double Add(int x, double d)
{return x + d;
}double Add(double d, int x)
{return x + d;
}
C++支持函数重载的原理
首先C语言是不支持函数重载的,而C++是支持函数重载的!
C语言不支持的原因可以从两方面解释。
1、历史角度:设计C语言的祖师爷当时觉得设计同名函数会导致误会,而且那时候没有碰到相应的场景,因此没有设计同名函数!
2、链接角度:C语言在生成可执行程序前需要编译链接,链接时去合并符号表和符号表的重定位时,是直接去拿函数名去找的(C语言没有同名函数)。
C++支持函数重载的原因是,C++会对重载的函数在编译时进行特殊的符号修饰,等到链接去合并符号表和符号表的重定位的时候,实际上是根据不同的函数名(进行修饰过的)进行找的!
OK,这里大概串一下编译链接的过程:
OK,再来分别看看C语言和C++的编译后的函数名的区别,由于windows函数修饰规则较为复杂所以我这里以Linux为例(add函数为例):
先通过C语言的编译器gcc进行编译到汇编停止(为了待会看符号表)
再来看看C++的:
通过C++的编译器g++进行编译到汇编停止(C++也是生成.o):
显然C语言生成的符号表的函数名和C++是不一样的,C语言就是纯函数名而C++是修饰过的函数名!这里再来介绍一下C++的函数名的修饰规则(Linux下)!
—Z + 函数名的字符个数 + 函数名 + 参数类型的首字符
所以上面C++编译后的符号表中的add是_Z3addii,因为它的函数名有三个字符,函数参数类型都是int所以是ii
二、引用
什么是引用?
引用(&)不是重新定义一个变量,而是给已经存在变量起一个别名,而是引用变量和被引用的变量共用一块空间!(语法上编译器不会为引用变量开空间)。
语法:类型& 引用变量(对象)名 = 引用实体
OK,举个例子:我们小时候看西游记的时候,孙悟空又称齐天大圣,齐天大圣就是孙悟空的别名即引用!
#include <iostream>
using namespace std;int main()
{int a = 10;int& b = a;cout << a << "--" << b << endl;b++;cout << a << "--" << b << endl;return 0;
}
这里b就是a的引用,他是a的别名,和a共用一块空间!既然他和a共用一块空间的话对b++,a也会+1,他们的地址应该也是一样的!
但我们刚刚上面说是语法上引用不开空间,那底层呢?底层会!引用的底层就是指针(汇编实现)!我们跳到反汇编看看:
我们平时学习时认为引用是不开空间的但也要知道底层编译器是开了的,它的大小是4/8个字节!
注意:引用类型必须和引用实体同类型的
不能这么乱搞!!!
引用的特性
1、引用在定义时必须初始化
2、一个变量可以有多个引用(就像孙悟空别名是齐天大圣、弼马温、猴子等)
3、引用一旦引用了一个实体,就不能在引用其他实体了
1、引用在定义时必须初始化
int main()
{int a = 3;int& b; return 0;
}
2、一个变量可以有多个引用
int main()
{int a = 3;int& b = a; int& c = a;int& d = c;return 0;
}
3、引用一旦引用了一个实体,就不能在引用其他实体了
int main()
{int x = 10;int y = 20;int& c = x;cout << x << endl;cout << c << endl;c = y;//此处不是改变了引用实体,而是把y赋值给了c引用的实体cout << c << endl;cout << y << endl;cout << &x << endl;cout << &c << endl;cout << &y << endl;return 0;
}
这里通过打印地址验证了引用一旦引用一个实体就不在改变!
常引用
这里的常引用实际上是权限的问题!
权限可以缩小、可以平移,但权限不能放大!
权限不能放大
int main()
{const int a = 10;//const 修饰的常变量具有常量的属性-->不可修改int& b = a;//权限放大return 0;
}
我们知道被const修饰的变量具有常量的属性--->不可修改,下面用非const 修饰的引用b去引用a,此时的b是可以修改的即放大了权限
解决办法-->缩小引用的权限让引用也被const修饰(权限可以平移):
权限可以缩小
int main()
{int x = 10;const int& y = x;//权限缩小return 0;
}
赋值是不涉及权问题
int main()
{const int x = 100;int c = x;//赋值return 0;
}
说道这里我又想到了一个小知识点 ---- 临时变量,我们顺便来介绍一下:
再谈临时变量
我们以前在C语言函数返回值的时候介绍过,函数返回会创建临时变量,临时的中间变量具有常量属性!当时没有细细的介绍只是一笔带过了!其实这条性质不只是返回值有效,对于类型转换(强转、隐式类型转换、整型提升、截断等)都是有效的!
OK,举个栗子:
int main()
{int a = 3;double d = a;return 0;
}
这里是典型的隐式类型转换,int转换为double ,其实他并不是直接把a(int)给转换为d(double)的,而是产生了中间变量的!这个变量是编译器产生并且默默处理的,无法调试看到!我画个图解释一下:
还有就是我们当时在模拟实现内存函数(memcpy、memmove、movecmp)的时候说过:强转具有临时性不能被修改!原因就是这!
OK,验证一下:
int main()
{int a = 10;double d = 9.9;int* pa = &a;double* pd = &d;(int*)(&d) = nullptr;return 0;
}
引用的使用场景
1、做参数
我们以前在用C语言交换两个整数时是不是得传指针呀,原因是形参是实参的一份临时拷贝,改变形参不改变实参,要改变实参必须传地址!今天有了引用就不用了,引用是别名,就可以直接修改了!
以前版本:
void Swap(int* x, int* y)
{int tmp = *x;*x = *y;*y = tmp;
}int main()
{int a = 3, b = 5;printf("交换前: a = %d b = %d\n", a, b);Swap(&a, &b);printf("交换后: a = %d b = %d\n", a, b);return 0;
}
引用做参数版本:
void Swap(int& x, int& y)
{int tmp = x;x = y;y = tmp;
}int main()
{int a = 3, b = 5;printf("交换前: a = %d b = %d\n", a, b);Swap(a, b);printf("交换后: a = %d b = %d\n", a, b);return 0;
}
介绍到这里,我们可以在实现数据结构的时候如果不带头的话必须得要二级指针的地方,有点不方便,今天介绍了引用后我们可以不用传二级了,直接把以及传过去行参用一个一级指针的引用接收即可!
没使用引用前:
typedef struct SLNode
{int data;struct SLNode* next;
}SLNode;//头插
void SLPushBack(SLNode** head)
{}SLNode* head = NULL;
SLPushBack(&head);
使用引用之后:
typedef struct SLNode
{int data;struct SLNode* next;
}SLNode, *SLLink;//头插
void SLPushBack(SLNode*& head)
{}SLLink head = NULL;
SLPushBack(head);
是不是看起来好点了,而且操作也会简单一点!使用引用的这种方式是很多教材上的样例!而且还在书皮上写着" 数据结构与算法 "C语言实现???我也寻思C语言没有引用啊???我当时一开始看到学校的教材时直接傻眼了!(自以为链表学的还行结果上机操作不来,当时还没学C++....)!我个人觉得第二种这种教材的写法应该告诉读者要提前学习C++引用。不然对新手极其不友好!!!!
2、做返回值
int& Count()
{static int n = 0;n++;return n;
}
int g_val = 0;
int& Count(int n)
{g_val += n;return g_val;
}
这两种写法是没有问题的!全局变量和静态变量都存在于静态区,他们的生命周期都是整个工程的生命周期。所以这里可以直接返回,因为出了作用域引用的实体(对象)还存在!
思考一下,下面这段代码对吗?能这样写吗?
int& Add(int a, int b)
{int c = a + b;return c;
}int main()
{int& ret = Add(1, 2);cout << "Add(1, 2) is :" << ret << endl;Add(3, 4);cout << "Add(1, 2) is :" << ret << endl;return 0;
}
先看结果:
你要是在公司写这样的代码出来,你的领导200%会找到你对你进行“鼓励+问候”的!!!你肯定想我第二次内接受呀为什么是7?这段代码问题实际是返回栈空间的地址,和我们C语言指针那一期介绍的那个一样!OK,我来画个图解释一下为什么会这样!
注意:引用是否做返回值,取决于出了作用域后引用的实体是否存在,如果存在可以用引用做返回值,否则用传值!
引用和传值的效率比较
我们以前讨论传值传参和传址传参哪个优时?我们的结论是:在修改参数的值时、参数大小较大时,传址是更好的原因是1、改变必须得要指针2、较大的参数如果传值时会产生拷贝,效率会降低!如果是传指针只是4/8个字节!!其实在适当的场景下引用的效率也是远远地高于传值的!我们来看看!
以结构体为例:
struct A
{int a[100000];char b;double c;
};
传参和传引用比较
这样一个结构体,它的大小是不是很大呀!如果传值过去的话形参就会产生拷贝导致效率很低!传引用的话,形参是实参别名,语法上不开空间!效率会高很多!
OK,我们来验证一下:
void TestA1(struct A a1){}
void TestA2(struct A& a2) {}int main()
{struct A a1;size_t begin1 = clock();for(int i = 0; i < 10000; i++)TestA1(a1);size_t end1 = clock();cout << "传值消耗的时间: " << end1 - begin1 << endl;size_t begin2 = clock();for (int i = 0; i < 10000; i++)TestA2(a1);size_t end2 = clock();cout << "传引用消耗的时间: " << end2 - begin2 << endl;return 0;
}
值返回和引用返回比较
一般的值返回是会形成一个拷贝的(大一点的是形成拷贝在放在被调函数的栈帧上,较小的会放在寄存器eax里面。这里我们在函数栈帧介绍过,就不在说了)然后返回,实际上我们看到的返回值在出了作用域后就会销毁!但如果是出了作用域对象还在的话,用引用返回的确比较好的多!但一定注意出了作用域实体得存在!
struct A
{int a[100000];char b;double c;
};
struct A a1;A TestA1() {return a1;
}
A& TestA2() {return a1;
}int main()
{size_t begin1 = clock();for(int i = 0; i < 10000; i++)TestA1();size_t end1 = clock();cout << "值返回消耗的时间: " << end1 - begin1 << endl;size_t begin2 = clock();for (int i = 0; i < 10000; i++)TestA2();size_t end2 = clock();cout << "引用返回消耗的时间: " << end2 - begin2 << endl;return 0;
}
总结:上述测试说明了值和引用在传参、返回值上的效率有很大的不同,如果较大的参数建议用引用传!一定注意的是:是否用引用返回时,取决于引用的实体等出了作用域还在!否则出问题!
引用和指针的区别
在语法上引用就是一个别名,没有独立空间,他和引用的实体共用一块空间!但在底层上是开了的,底层是用汇编语言通过指针实现的!
语法上没开空间:
int main()
{int a = 10;int& b = a;cout << &a << endl;cout << &b << endl;return 0;
}
底层开了空间:
int main()
{int a = 10;int& b = a;int* p = &a;return 0;
}
主要区别:
1、引用语法上是定义一个变量的别名,指针存储着一个变量的地址
2、引用在定义时必须初始化,指针没有要求
3、引用在引用了一个实体后就不能在引用别的实体了,指针可以在任何时候指向同类型的实体
4、引用没有空引用,指针有空指针
5、sizeof下的含义不同:引用的结果为引用实体类型的大小,指针的结果为4/8(x86和x64)
6、引用自加引用的实体会加一,指针自加指针会偏移一个指向类型的字节大小
7、引用没有多级,指针有多级
8、访问实体的方式不同:引用不需要显示处理编译器会处理,指针需要显示解引用
9、引用相较于指针更安全
三、内联函数
什么是内联函数?
被inline修饰的函数叫做内联函数!在编译时C++编译器会在调用内联函数的地方直接展开而不在建立函数栈帧了!(这提升了程序的运行效率)
OK,举个栗子:
不是内联函数(有函数栈帧):
int Add(int x, int y)
{return x + y;
}
内联函数(不会创建函数栈帧):
inline int Add(int x, int y)
{return x + y;
}
由于小编的是VS2019在这块优化的有点大,调试不来,我们在VS2013上看看:
内联函数的特性
1、C语言不支持内联函数
2、内联(inline)是一种典型的空间换时间的做法,如果编译器把函数当做内联来处理,在编译阶段会直接在原地展开而不去调用(call)。
这样做的优点是:少了栈帧开销,程序的运行效率提高了,缺点是:直接展开如果调用的地方过多会使目标文件(可执行程序)变大!
OK,举个栗子:
3、inline对于编译器而言只是一个建议,不同的编译器对于inline的实现不同!但一般是较小规模的函数(一般不超过10行,具体各个编译器不同)、非递归、频繁调用的函数编译器会采用inline修饰即在调用地展开,否则编译器会直接忽略掉inline的特性!
下面是《C++prime》第五版关于inline 的建议:
OK,举个栗子(上面那个已经验证过内联了,我们来个编译器忽略的):
inline int fun(int n)
{int x = 10;int q = 100;int w = 1;int a = 3;int z = 2;int c = 21;int v = 5;int f = 6;int g = 7;int u = 9;return x + 10;
}
我个人觉得这应该是防止程序员乱搞的一种机制!不然编译器不制止的话,有的给你到处无脑inline,程序就会很大很大!有了这个机制后就相当于是程序员向编译器发出请求,编译器觉得不合理了就可以拒绝!
4、inline是不建议申明和定义分开的,如果分离会在链接时找不到inline函数!因为inline是在调用地直接展开的没有地址,在编译完之后进行链接符号表合并的时候就找不到inline!
C++中的内联的作用实际上是为了解决C语言的宏的缺陷的!我们顺便来回忆一下宏的优缺点!
宏的优缺点
优点:
1、增强了代码的复用性
2、提高性能
缺点:
1、不方便调试(在预处理阶段就已经替换了)
2、导致代码的可读性差、可维护性差、容易误用
3、没有类型检查
别的不说,基本功不扎实的写一个简单的加法的宏都会写错!下面是典型的例子(看看有没有你的影子)。
错误版本:
//#define ADD(int x, int y) return x + y;
//#define ADD(x, y) return x + y;
//#define ADD(x, y) x + y;
//#define ADD(x, y) (x + y)
//#define ADD(x, y) (x) + (y)
下面这三个还是能理解的,上面那两个是真的无语的那种!!!有的伙伴看不出下面三种哪里不对,我来分别用一个例子演示:
#define ADD(x, y) x + y
int main()
{int ret = ADD(2 , 3) * 5; // 替换后--> 2 + 3 * 5return 0;
}
#define ADD(x, y) (x + y)
int main()
{int ret = ADD(2 | 1, 1 & 3); // 替换后--> 2 | 1 + 1 & 3return 0;
}
这里+的优先级比&|高得多,与我们的期望值计算结果不一样!
#define ADD(x, y) (x) + (y)
int main()
{int ret = ADD(2,3) * 6; // 替换后--> (2) + (3) * 6;return 0;
}
下面这个也是替换后的优先级有问题!下面我们来看看正确的写法:
#define ADD(x, y) ((x) + (y))
C++的内联函数实际上是很看不起宏的,它的语法细节太多了,稍不注意就会出错!所以C++是通过内联函数替代宏、用const 和 enum来替代常量。这就完美了解决了C语言宏的缺陷!内联可以调试,有类型检查,可读性强!
四、auto关键字(C++11)
随着学习的深入,程序变得越来越复杂,程序设计的类型也越来越复杂!这就会导致两个问题的产生:1、类型难于拼写 2、含义不明确导致出错
OK,举个栗子:
#include <string>
#include <map>
int main()
{std::map<std::string, std::string> m{ { "apple", "苹果" }, { "orange","橙子" },{"pear","梨"} };std::map<std::string, std::string>::iterator it = m.begin();return 0;
}
std::map<std::string, std::string> 这么长的类型就问你恶不恶心?
这里你肯定说可以用typedef 重命名,的确可以!
#include <string>
#include <map>typedef std::map<std::string, std::string> Map;
int main()
{Map m{ { "apple", "苹果" },{ "orange", "橙子" }, {"pear","梨"} };return 0;
}
但有时候typedef会把你坑死~!再来看个栗子:
typedef char* pstring;
int main()
{const pstring p1; const pstring* p2; return 0;
}
这样写对吗?是不是连你都控制不住了!OK,这里是p1有问题p2可以的!为什么p1不行p2可以呢?我来解释一下:先看运行结果:
这个就对基本功考察的很深了!而且很难控制和推导出类型!为此C++11引进了一个关键字:auto
关于auto有伙伴可能听过一些!没错他就是C语言中最没有存在感的关键字!在每个局部变量前都有一个隐藏的auto,我们一般不写,也可以显示的写出来:
#include <stdio.h>
int main()
{auto int a = 10;printf("%d\n", a);struct A{int b;};auto struct A b;return 0;
}
auto关键字介绍
在早期 C/C++ 中 auto 的含义是:使用 auto 修饰的变量,是具有自动存储器的局部变量,但遗憾的是一直没有人去使用它! C++11 中,标准委员会赋予了 auto 全新的含义即: auto 不再是一个存储类型指示符,而是作为一 个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得到。
举个栗子:
int main()
{int a = 6;cout << a << endl;auto x = 10;cout << x << endl;auto d = 6.6;cout << d << endl;auto c = 'a';cout << c << endl;return 0;
}
这样写可以吗?
int main()
{auto a;cout << a << endl;return 0;
}
肯定不行的!为什么呢?人家说的很明确:
auto声明的变量必须由编译器在编译时期推导而得到。换句话说用auto声明变量时,它的底层编译器会根据右边的类型确定auto的实际类型的!这里啥都不给让编译器推到啥???
那我们平时怎么知道auto申明的变量或返回的类型是什么类型呢?这里介绍一个typeid关键字来查看!如下:
typeid(查看对象名).name()
char Test()
{return 'a';
}int main()
{auto d = 6.6;cout << d << endl;cout << typeid(d).name() << endl;auto ret = Test();cout << ret << endl;cout << typeid(ret).name() << endl;return 0;
}
总结:
在使用auto定义变量时必须初始化 ,因为在编译期间编译器要根据初始化的内容去推导auto的实际类型!所以auto并不是一种类型的声明,他是一种类型的 占位符 ,在编译期间编译器会将他替换成实际的类型!
auto使用细节
1、auto声明指针变量时,auto和auto*没有区别,但auto声明引用类型时则必须加&
int main()
{int a = 10;auto p1 = &a;auto p2 = &a;cout << typeid(p1).name() << endl;cout << typeid(p2).name() << endl;auto& b = a;return 0;
}
2、在同一行定义多个变量时,多个变量的类型必须是一样的,否则会报错!原因是:编译器会只推导第一个类型,然后用这个类型去定义后面的其他变量!
int main()
{auto a = 10, b = 20;//这里不会报错auto d = 6.6, c = 'a';//这里会报做return 0;
}
3、auto不能做函数的参数
void Test(auto x)
{//...
}
4、auto不能声明数组
int main()
{auto a[] = { 1,2,3 };return 0;
}
auto其实还有其他用法例如配合范围for和lambda表达式使用!后期介绍到了再详解~!
五、范围for(C++11)
void TestFor()
{
int array[] = { 1, 2, 3, 4, 5 };
for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)array[i] *= 2;
for (int* p = array; p < array + sizeof(array)/ sizeof(array[0]); ++p)cout << *p << endl;
}
范围for语法
for 循环后的括号由冒号 “ : ” 分为两部分:第一部分是范 围内用于迭代的变量,第二部分则表示被迭代的范围 。for( 集合每个元素的类型 变量名 :集合) {}他这里实际上是从集合中拿出一个元素赋值给前面声明的变量,然后自动++,他会自动判断结束的!!
void TestFor()
{
int array[] = { 1, 2, 3, 4, 5 };
for(auto& e : array)e *= 2;
for(auto e : array)cout << e << " ";
return 0;
}
注意:与普通循环类似,可以用 continue 来结束本次循环,也可以用 break 来跳出整个循环 。
范围for的使用条件
1、for循环迭代的范围必须是确定的对于数组而言,就是数组中第一个元素和最后一个元素的范围 ;对于类而言,应该提供begin 和 end 的方法, begin 和 end 就是 for 循环迭代的范围。
void TestFor(int array[])
{for(auto& e : array)cout<< e <<endl;
}
答案是错的!我们上面说过!范围for迭代的范围必须是确定的!这里array是一个数组,但我们知道虽然他这么写看起来是数组,但他的本质是一个指针,arrasy的范围是不确定的!所以无法遍历!
2. 迭代的对象要实现++和==的操作。 (关于迭代器,以后会介绍,现在提一下,知道有这个东西就好了)
六、指针空值nullptr(C++11)
在良好的C/C++编程习惯中应在声明一个变量时给这个变量一个合适的初始值,否则可能出现不可预料的错误!例如指针不初始化。C语言中如果一个指针没有合适的指向的话我们一般会给他置NULL(NULL实际上本质就是0),但在C++中会出现一些问题!
int main()
{int* p1 = NULL;return 0;
}
上面定义中我们看到了NULL被定义成了,字面常量0或被定义为无类型的指针的常量void*。这样定义会在成一些问题,看如下代码:
void Test(int)
{cout << "Test(int)" << endl;
}void Test(int*)
{cout << "Test(int*)" << endl;
}int main()
{Test(0);Test(NULL);return 0;
}
这样写是不是有点麻烦呀!这其实是C++在这里的一个缺陷!后来C++11为了解决这个缺陷对这里打了补丁 ---> 用nullptr替换NULL
注意:1. 在使用 nullptr 表示指针空值时,不需要包含头文件,因为 nullptr 是 C++11 作为新关键字引入 的 。2. 在 C++11 中, sizeof(nullptr) 与 sizeof((void*)0) 所占的字节数相同。3. 为了提高代码的健壮性,在后续表示指针空值时建议最好使用 nullptr 。