目录
1.什么是C++?
C语言是结构化和模块化的语言,适合处理较小规模的程序。对于复杂的问题,规模较大的程序,需要高度的抽象和建模时,C语言则不适合,为了解决软件危机,20世纪80年代,计算机界提出了OOP(object oriented programming):面向对象思想,支持面向对象的程序设计语言应运而生
1982年,Bajarne Stroustrup博士在C语言的基础上引入并扩充了面向对象的概念,发明了一种新的程序语言。为了表示该语言与C语言的渊源关系,命名为C++。因此:C++是基于C语言产生的,它既可以进行C语言的过程化程序设计(兼容C语言),又可以进行以抽象数据类型为特点的基于对象的程序设计,还可以进行面向对象的程序设计。
C++很多设计都是对C语言不足之处的改进和优化,同时C++向下兼容C语言语法。
C++总计有63个关键字,几乎比C语言多了一倍,前面C语言学习的关键字也包含在里面。
2.命名空间
我们一般见到的第一个C++程序就是
#include<iostream>
using namespace std;
int main()
{
cout << "hello world" << endl;
return 0;
}
先抛开函数内部的代码,我们第一个看到的与C语言的不同就是这个 using namespace std;,这一行代码是什么意思呢?放在这里有什么意义呢?
要了解这行代码的意义,我们首先还要了解另外一种常见的写法。
#include<iostream>
int main()
{
std::cout << "hello world" << std::endl;
return 0;
}
在这个程序中,我们把上面的一行代码删了,但是在主函数中却多了两个 std:: ,这又是什么意思呢? 在我们学了下面的只是之后,我们就能理解这些是什么意思了。
首先我们要了解,C语言的第一个不足之处就是命名冲突,比如我们在函数中定义一个变量 scanf ,当我们想要用 scanf 函数来输入对其赋值时,编译器会报错:scanf 重定义,或者说 scanf 不是一个函数 ,这就是C语言的命名冲突。我们前面学习C语言的时候,我们要避免变量命名与库中的函数名或特殊的符号冲突。当我们的项目很大需要多人各自编写一个文件时,不同文件之间是有很大啊几率会出现同名的情况的,这时候C语言是无法解决这个问题的。
于是C++就引用了一个概念,命名空间,前面的 namespace 就是命名空间关键字。命名空间就是定义一个域,这个域就叫命名空间域。
在C语言中我们知道作用域的概念,变量按作用域可以划分为全局变量和局部变量,局部变量和全局变量是可以同名的,但是用的时候局部有限。如果我们就是想要访问全局的那个变量而不是局部域里的那个,在C语言中是做不到的。而在C++中,我们可以使用 空格 加 :: 加变量名来实现, ::叫做与作用限定符,如果他的左边是空白,默认是全局域。
#include<iostream>
using namespace std;
int a = 10;
int main()
{
int a = 20;
cout << ::a << endl;
return 0;
}
这样一来我们就直接访问全局域而不是优先去使用局部域的变量了。
namespace如何使用呢?
namespace space_name
{
//vcriate list
}
namespace后面加 命名空间名 ,然后加一个花括号,花括号中我们可以定义想要的变量。比如我们可以一个 game 的命名空间,在里面创建 两个变量 boss 和enery
namespace game
{
int boss = 1;
int enery = 20;
}
在命名空间中不仅可以定义变量,还能定义函数和类型。
那么在这里面的变量是全局变量还是局部变量呢?
我们要清楚一点,只有定义在函数内部的变量才是局部变量,包括main函数内部定义的变量,局部变量都是存在栈区的,而命名空间只能在全局范围内定义,不能定义在函数或者结构体中,所以命名空间里面的变量是全局变量,存储在静态区,命名空间不会影响变量的生命周期,只是一个限定域,影响的是编译器的查找规则。
编译器的查找规则有两种:
1.没有指定域,就是默认的,现在局部找,再去全局找
2.指定域,只在制定的域里面去找,找不到就报错。
定义在命名空间中的全局变量与直接定义在全局的全局变量有什么区别呢?理解起来不难,参考上面的查找规则2,我们如果要访问命名空间中的变量,我们就必须指定域 ,通过 命名空间名+ : : +变量名的方式去访问,这时候如果在全局或者其他文件或者其他命名空间中定义了同名的变量,还会有命名冲突了吗?因为我们只会在指定的命名空间中去查找,就算找不到也不会去全局或者其他的命名空间中去找,所以就避免了命名冲突。同时,这也说明,在一个命名空间中也是不能定义同名的变量的。
我们是不是可以说命名空间就像一堵高墙,把里面变量围了起来,这样一来相当于保护了里面的变量,避免了命名冲突,而 : : 就相当于我们知道了找到了这堵围墙的入口,但是这个入口是被锁住了,而命名空间名就是这个入口的钥匙,拿到了钥匙 找到了入口才能访问命名空间中的变量。
命名空间也是可以嵌套定义的,比如下面这一个定义
namespace N1
{
int a = 10;
int b = 20;
namespace N2
{
int a = 30;
}
}
这时候,N2就是嵌套定义在N1内部的,N1和N2内的变量是可以同名的,因为他们可以说不是同一个级别的命名空间,如果我们要访问N1里面的a,我们就可以直接 N1::a,如果要访问N2里面的a变量呢?我们可以这样访问 N1::N2::a ,这样就是访问命名空间N1 里面的命名空间N2 中的变量a 。
int main()
{
std::cout << N1::a << std::endl;
std::cout << N1::N2::a << std::endl;
return 0;
}
虽然N1和N2不是同一级别的命名空间,但是他们里面创建的变量都是全局的。
那么我们还可以可以思考一个问题,我们知道程序的预处理阶段就已经把头文件展开了,那么头文件中的函数和变量等就是在全局域中的,那么我们能不能直接访问到呢?答案是不能的。
#include<iostream>
int main()
{
cout << 10 << endl;
}
比如这一段代码,cout和endl是头文件iosteam中的函数,如果我们直接这样写的话,编译器就会报错,
为什么会报标识符未定义这样的错误呢?我们明明已经包含头文件了,而且头文件已经展开了。这就回到了我们一开始 见过的 using namespace std;这一行代码了。
std是C++官方库定义的命名空间。 而官方库又不是只有一个文件,但是 std 这个名字只有一个,如果一个程序包含多个头文件时会怎么做呢? 编译器是支持同一个工程存在多个同名的命名空间的,编译器最终会把他们合并为一个命名空间。对于C++的头文件,每一个头文件展开都是封装在一个std命名空间中的,最后编译器会把它们合并为一个std。这也就解释了为什么前面我们直接使用cout和endl会报错,因为他们是封装在命名空间std中的,而不是直接裸露在全局中的,我们不能直接访问,而是要指定域去访问。
这里我们就解决了前面为什么不能直接用cout和endl的原因了,因为他们是封装在 std 中的,而我们没有指定 域 去查找的话,编译器只会在局部和全局中去找,并不会到命名空间中找,所以编译器会报错。
还有一点要注意,因为同名的命名空间会合并,所以我们不能在同名的命名空间内定义同名变量。
还有一点,由于C++时兼容C语言的,而C语言中是没有命名空间这个概念的,那么C语言的头文件展开就会保留C语言的做法,他不会放到命名空间中去,而是直接展开在全局。
那么我们发现了一个问题,就在上面的代码中
int main()
{
std::cout << N1::a << std::endl;
std::cout << N1::N2::a << std::endl;
return 0;
}
我们使用库里面的函数和变量的时候难道每一次都要用 std:: 再加变量或函数名吗?用起来会不会太麻烦了? 除了用域限定符,我们还有其他的方法可以访问到std中的内容
我们可以用 using namespace + 命名空间名的方式,将该命名空间的访问权限放开,这样一来这个命名空间里面的函数和变量就裸露在全局中了,默认的查找规则也能找到。这也就是我们第一个程序中的 using namespace std;的含义。
这样虽然使用起来方便了,但是不就相当于又回到了C语言了吗?命名冲突的问题又回来了,而且在项目里面这样做事会有很大的风险的。
这时候我们还提供第三种访问方式,就是之放开部分权限,叫做部分展开或者说叫指定展开。怎么用呢?
using std::cout;
using std::endl;
int main()
{
cout << 10 << endl;
return 0;
}
我们可以用 using +命名空间名 + : : +要放开的函数或变量。
这种方法的意义就是我们可以指定要放开的内容,而不影响命名空间中的其他内容,其他的内容还在命名空间的保护之中。这种方法能把我们常用的函数展开出来,这样我们用的时候就会简单一点,同时我们定义变量的时候只需要避免和这些常用的崇明就行了。
总结:命名空间适用于名字隔离的,解决了C语言中的命名冲突问题。
3.C++输入和输出
目前我们讲的输出就是
cout<<输出的内容
int a = 10;
cout << a<<endl;
cout << "a=" << a << endl;
如果有多个内容我们要用<<来隔开要输出的多个内容
在目前这个阶段,我们可以把 cout 理解为控制台,c指的是console,而 << 被称为流插入运算符,相当于把这些字符串等内容流入我们的控制台。在C语言我们也学过<<,这是一个左移操作符,而在C++中因为有运算符重载,在这里时流插入运算符。 而 endl 就相当于是 '\n' ,是换行。
cout相比于printf的特点就是自动识别类型,我们可以看到上面我们是直接写的输入的内容,而不用指明他的格式,而print则是要表明类型和格式。
而输入则是 cin
cin>>
int a ,b;
cin >> a >> b;
cout << a<<endl;
cout << "b=" << b << endl;
>>是流提取运算符,相当于把数据从流中提取了出来放到了本来所在的地址。如果要输入多个变量,要用>>隔开。
4.缺省参数
缺省参数是什么呢?我们在函数的声明中可以指定形参一个默认值,如果我们调用时传了参数,这时候这个默认值就不会用,,而使用我们传的参数,如果我们没有传参数,他就会用这个默认值。
void func(int n = 20)
{
cout << n << endl;
}
int main()
{
func(10);
func();
return 0;
}
这个默认的参数的值就是我们说的缺省值。如果我们不给缺省值,那么在调用的时候就必须传参,否则编译器会报错。而给了缺省值,我们可以传也可以不传。
如果函数有多个参数,我们可以全部都给缺省参数
void func(int a=10,int b=20)
如果是全缺省的话,我们传参就有多种传法了,以什么的函数为例,第一种就是不传参,都用缺省参数,第二种就是传一个参数,这时候第一个缺省参数就会用我们传的这个参数,第二个参数用缺省值。我们还可以传两个参数,这时候a和b都是用我们传过去的参数了。但是要注意的是,我们不能跳过第一个缺省参数去给第二个传参,func( , 10),这种写法是错误的。C++缺省传参只能从左往右传。
既然多个参数可以全缺省,那么我们也能半缺省
void func(int a , int b = 20)
半缺省并不是说一半的参数给缺省参数,更准确来说是部分缺省
void func(int a, int b = 10,int c = 20)
对于这样的部分缺省参数,我们就至少得传一个参数才能调用函数了。我们也可以传两个参数,或者传三个参数。
注意:部分缺省只能从右往左部分缺省,而不能这样用 void func ( int a, int b=10, int c),必须要从右往左缺省,避免歧义,因为缺省参数传参时从左往右依次传的,无法跳着穿,这时候如果我们只传两个参数,那么我们第二个参数到底是传给b的还是传给c的呢?这是有歧义的,所以C++直接规定了只能从右往左缺省。
缺省参数不能在声明和定义中同时出现,要不就在声明中写成缺省参数,要不就在定义中写成缺省参数,但是一定不能同时都写成缺省参数,编译器会报一个错叫重定义缺省参数。这样做是为了防止有人粗心大意,在声明和定义中都写了缺省参数,缺省值却不一样,这样的话编译器就会凌乱了,到底用哪个编译器也搞不清楚,所以就直接禁止这种写法了。 我们一般写缺省参数还是写在头文件的声明中,在定义中就写成普通的参数形式。
缺省参数有什么用呢?
我们可以拿以前的顺序表的初始化来举例,以前我们实现的时候,初始化顺序表的时候是不传参也不给数组开辟初始空间的,但是有时候我们是知道顺序表一共要存多少元素,要开多少空间的,这样一来,一直让他扩容是不是降低了效率,这时候我们就可以在初始化的函数给一个缺省参数,比如4 ,如果我们不确定要存的数据个数大小,我们就开一块 4 个数据的数组空间,如果我们真的要存多少数据,就传一个参数,这样我们就能一次性把所需的空间开辟好,避免了扩容造成的性能下降。
所以说,缺省参数在一些特殊场景是很有用的。
5.函数重载
函数重载是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,但是要求他们的参数列表不同,要么是个数不同,要么是类型不同,要么是顺序不同。
这三种情况都是什么样的呢?
个数不同
int add(int x, int y);
int add(int x, int y, int z);
类型不同
int add(int x, int y);
double add(double x, double y);
double add(int x, double y);
顺序不同其实本质上还是参数类型不同,指的是形参的个数和形参的类型一样的情况下,它们的顺序不同。函数参数识别是按类型来识别的,而不是按形参名来识别的,
void func(int a, char c);
void func(char c, int a);
//void func(int c, char a);//这不是顺序不同,形参名是没有意义的
函数重载也是可以用缺省参数的,比如:
void func(int x, int y = 10);
void func();
但是我们要注意缺省参数不要和其他重载函数的调用存在歧义,比如下面的这两组:
//存在歧义的函数重载,编译器会报错
void func(int a = 10, int b = 20);
void func();
void func(int x, int y = 10);
void func(int x);
这两组就是典型的存在歧义的重载,他们的参数列表是不同,确实构成函数重载,但是当函数传不传参数是,第一个和第二个声明的函数都满足参数,都可以调用,或者只传一个参数时,第三个函数声明和第四个都满足参数的条件,这时候编译器会报错:函数调用不明确,也就是二义性,我们一定要避免这种情况。
函数重载的原理我们在后面还会详细讲,这里我们就以粗浅的形式来了解一下。
我们知道,C语言是不支持函数重载的,而C++支持函数重载,那他是怎么支持的呢?
我们可以拿一个C语言的出现的报错来看一下,以最简单的只声明不定义来说
而在C++中是这样的
我们可以发现,在C语言中,函数名就是我们写的函数名,而在C++中,函数名却变了,加了一些修饰,修饰的字符与参数类型和其他的一些东西的有关,这时候我们就能发现区别了,C语言的函数名写到符号表中的就是函数名本身,而C++中则是加了修饰,这时候如果参数列表不同,他的修饰就不同,写入符号表中的函数名就不同,每一个重载都关联了一个地址,我们用哪种传参就会去找到这个函数名和函数的地址来调用,这就是C++能够实现函数重载的原因。
返回值不同能不能构成函数重载呢?
不能。我们表面上看来好像是因为返回值不参与函数名的修饰而导致返回值不同不构成函数重载的,其实不然。真正的原因并不是函数名修饰规则,因为函数名修饰规则本身也是人定的,那为什么不设计成与返回类型相关呢? 本质原因还是因为返回值类型不同会造成二义性,在函数调用的时候是不会指定返回类型的,因为在C/C++程序中,我们有时候会忽略函数的返回类型,比如我们的 printf 和 scanf 函数,他们都有返回值,但是我们一般都不会用一个变量去接收他们的返回值。这样一来,返回类型就不能唯一标识一个函数了,如果我们写了两个函数,他们的参数列表相同,但是返回值一个是void一个是int,我们调用这个函数,却不接收返回值,那么到底是想要调用哪个函数还是不明确的。
6.引用
引用时C++中一个非常重要的概念,在C++中,引用是为了简化指针,但是他在语法上跟指针又是不一样的概念,C++中引用无法完全替代指针,而java中引用则完全替代了指针。
引用是怎么是写的呢?
我们可以将其与指针做一个对比
int a = 10;
int& ra = a;//引用
int* pa = &a;//指针
引用的定义方法就是 引用的变量类型 & 引用变量名,我们也可以简单理解引用和指针区别就是 类型后面加 & 是引用,变量前面加 &是取地址。
从语法上来说,引用就是给一个变量取别名。一个变量时可以有多个别名的。
int a = 10;
int& ra = a;
int& rra = ra;
int& b = a;
这里的意思就是,这块空间这时候有四个名字,a,ra,ra,b都代表这个变量,他们的地址都是同一个,引用的变量是不会去另外开空间的,他们只想的都是同一块空间。
定义引用的时候我们要注意:
引用在定义的时候必须初始化,指明引用的实体。
引用一旦引用实体,就不能再引用其他实体。
int a = 10;
int b = 20;
int& ra=a;
ra = b;
大家不妨猜一下这段代码是什么意思?
首先我们定义了一个a的引用变量,也就是给a取了一个别民, ra=b 是修改 ra 的引用对象吗?不然,这行代码的意思其实不是修改,而是赋值 ,相当于把 b 的值赋给 ra ,而ra又是a的别名,也就是将a的值改成了b的值。
引用的常见使用场景
1.做参数
我们经常用到的交换函数 swap ,当我们想要实现交换功能时 ,如果传的参数是 两个变量的话,形参会对实参进行拷贝,用作函数参数 ,这样子改变形参是不会影响实参的。在之后我们用指针完成了交换的逻辑。而在C++中,我们还可以用引用来实现交换 。
void swap(int& x, int& y)
{
int tmp = x;
x = y;
y = tmp;
}
当我们的参数是引用的时候,调用swap传两个变量,这时候形参就是对实参的引用了,而不是一份临时拷贝。形参是实参的引用,修改形参也就相当于修改实参了,这样一来我们就能实现与指针相同的逻辑。
引用做参数,这种参数叫做输出型参数。比如我们在二叉树的重构的时候,我们传了一个字符串的地址和一个下标的指针,这个下标的指针就是输出型参数,而学了引用之后,我们就可以直接传引用,可以减少解引用操作的次数。
引用的初始化是发生在函数建立栈帧时,形参传实参过去的时候给实参取的别名,而不是实参的临时拷贝。
那么传值和传引用构不构成函数重载呢? 答案是构成,但是他们会有歧义,因为形参是int 和形参是 int&的时候,我们传实参时,他们两个函数的形参类型都满足调用条件。这说明了函数名修饰的时候他们会有区别,但是他们的调用会有歧义。
2.做返回值
int count()
{
int n = 0;
n++;
return n;
}
首先我们要知道这样一个函数的返回值,它的返回值是存在一个临时变量中的,return n并不是直接把 n 就传递给了上一层栈帧 ,产生一个临时变量,这个临时变量如果比较小,就存在寄存器eax中,通过寄存器返回给上一层栈帧,如果比较大就会提前在上一层栈帧中开一块空间来保存。为什么会产生一个临时变量呢?因为如果 n 不是静态的,那么在调用结束时销毁栈帧的时候n也跟着一起销毁了,这时候再返回给上一层栈帧的话,这块空间已经不属于我们的程序了,他有可能还是原来的n ,有可能被清理成了随机值,也有可能被其他操作覆盖了,这时候我们再去访问这块空间的话,就相当于越界访问了,而且结果是不可预知的。 而如果我们将 n 拷贝一份存在寄存器中(小)或者先提前在上一层的某个位置(大)提前开好,这样一来,即使函数栈帧销毁了,我们也能得到该函数的返回值。
所以对于传值返回,不管是存在寄存器中,还是在上一层的栈帧提前开一块空间,他们都是用一个临时变量来返回的,会多一次拷贝操作。
而如果我们用引用返回的话。
int& count()
{
int n = 0;
n++;
return n;
}
我们返回的就是 n 的引用,也就是给 n 取了一个别名,这样是不会进行拷贝操作的,而返回n的别名就相当于把 n 返回了。但是当我们用一个 ret 接受了这个函数的返回值时,当我们要用 ret 的时候,我们要去访问 ret 的空间来获取它的值,而这时候函数栈帧已经销毁,原来 n 的空间已经不属于我们的程序了,就出现了我们上面说的问题,也就是越界访问,并且我们的结果是不可预知的。
如果我们写下这样一段代码
int& count()
{
int n = 0;
n++;
return n;
}
void func()
{
int x = 100;
}
int main()
{
int& ret = count();
cout << ret << endl;
func();
cout << ret << endl;
return 0;
}
输出的结果是不是 两个1 呢?这是不确定的。
为什么 ret 的值变成 100 了呢?可能是因为,count 函数和 func 函数开创的栈帧的大小是一样的,以为他们都只创建了一个整型变量,而恰好 x 的位置就是之前 n 的位置,所以这时候原来的 n 的空间就被后面的func函数的x覆盖了,而后续操作系统 又没有对这块空间进行清理,所以他就打印了 x。
但是如果我们不用引用变量接收返回值
int main()
{
int ret = count();
cout << ret << endl;
func();
cout << ret << endl;
return 0;
}
这时候 ret 就不是接受 n 的别名了,而是被赋值为 n 的别名(引用)的值,这就是赋值操作了,如果这时候 n 的空间还没被覆盖,那么 ret 的值就是1 ,后续调用 func函数也是不会影响外面的ret的。
但是如果我们返回的是一个静态变量的引用,这时候就不会有问题了,因为静态变量是存储在静态区的,而函数栈帧销毁 是不会影响静态区的变量的。那么这时候 引用返回和引用接收返回值都是没问题的,但是这时候我们就有要注意一个地方,如果是引用接收返回值的话,那么 ret 就是静态区中的那个变量的引用,当再次调用函数,如果修改了这个静态变量,ret 也变了。
int& count()
{
static int n = 0;
n++;
return n;
}
int main()
{
int& ret = count();
cout <<ret<< endl;
count();
cout << ret << endl;
return 0;
}
传引用返回的价值是什么呢?
1.传引用返回能减少拷贝,提高效率,主要体现在返回变量很大的时候,比如返回一个很大的结构体,这时候我们用引用返回就能提高效率。
2.修改返回值,引用做返回值,当我们用一个引用变量接收的时候,我们对引用变量的修改就是对该函数的返回变量的修改。
const引用
我们要了解一个概念,指针和引用赋值时,权限可以缩小,但是不能放大。
const int a = 10;
int& ra = a;
int* pa = &a;
当我们写下这样的代码时,编译器时会报错的。
因为a的类型是const int ,他是一个只读变量,也就是不能对其修改,而如果我们用 int& 或 int * 来定义引用和指针时,int& 和 int* 变量是可读可写的,这就相当于放大了权限。权限缩小和放大并不是说 原变量的权限变大变小,而是说我们定义的这个引用或者指针变量的权限相对于原变量的权限。
而如果是一个可读可写的 int 变量,我们定义它的指针和引用是,则可以用 const int*和const int&,因为这是一种权限的缩小。
int a = 10;
const int* pa = &a;
const int &ra= a;
这个东西用在哪里呢?当我们传参为了减少拷贝而采用引用的形参时,如果我们不想要修改实参的值,这时候我们在形参部分就可以用 const修饰引用形参,防止我们误操作而改变了实参的值。
void func(const int& ra);
说到权限的问题,我们就有可以回到之前的引用做形参的场景,当我们用引用做形参时
void func(int& ra);
从效率上来说是好的,但是这也意味着,我们调用这个函数时,传参就受限。我们调用这个函数的时候就只能传 int 类型的值,而不能传 const int 的值了。所以引用传参我们一定要用对地方。
我们用引用做参数的时候,一般都是const修饰,这样一来即可接收普通的 可读可写的实参,也可以接收只读的参数。但是具体的实现还是要看函数的功能而定。
引用做缺省参数时应该怎么写呢?
如果用的时 int& 的引用来做缺省参数,编译器会报错,因为缺省值给的是一个常数,是常数的话我们就不能用 int& 来引用,而要用常引用 const int & 。
还有一种场景就是
我们发现用 int& 来对 b 引用时会报错,而用const int& 却不会。这就要说到我们前面函数传值返回提到的临时变量了,其实在类型转换时也会产生一个临时变量,这一点很容易理解,如果没有一个中间变量,那么对b类型转换的时候岂不是把b也给修改了?而语法规定这些临时变量具有常属性。而这里的引用的对象并不是 b 这个变量,而是 b 发生隐式类型转换所产生的临时变量,所以我们要用常引用。
与之相同的还有这一种场景。
double add(int x, int y)
{
return x + y;
}
int main()
{
const int& ra = add(1, 3);
return 0;
}
在这个引用的过程中进行了两次类型转换。一次是函数返回的时候,从 int 类型转换成返回类型 double ,这是一次隐式类型转换(低精度转换为高精度),第二次是函数返回的临时变量由 double 转换成 int 的临时变量,这是一次强制转换(高精度转换为低精度),这时候我们也要用一个常引用来接收返回值的临时变量,而不能用普通的引用。 隐式类型转换是编译器自己完成的,因为C++是一门强类型的语言,对类型检查十分严格,隐式转换会给程序员带来很多的遍历。
在语法上面,引用是变量的一个别名,不开空间。但是在底层实现,是给 ra 开了空间的,在反汇编中我们能看到,对引用的操作和对指针的解引用操作的汇编代码是一样的,我们就能发现,引用在底层就是用指针来实现的。在定义引用的汇编指令中,我们可以看到编译器将 a 的地址存放到了ra中,lea指令就是取地址的指令。这说明ra其实在底层还是开了空间的,只是我们在语法逻辑上说ra是不开空间的,与 a 指向的同一块空间
除了两个变量的名字,我们的汇编指令都是完全相同的。 我们要了解这一点,在底层引用是指针,传引用在本质上失传了变量的地址。 但是在语法层,我们说引用是 是变量的别名,不额外开辟空间。
C++中引用不能完全替代指针的原因:引用初始化之后就不能改变引用的对象了,而指针却可以。如果只有引用而没有指针的话,就那一个我们写过的链表来说, 如果 next 是引用的话,增删查改就无法完成了。 所以说,在C++中,引用和指针是相辅相成的。
引用和指针的不同点:
1.引用在概念上是定义一个变量的别名,指针存储一个变量地址。
2.引用在定义时必须初始化,而指针则没有要求。
3.引用在初始化引用一个实体后,就不能再引用其他实体,而指针则可以在任何时候指向任何一个同类型的实体。
4.没有空引用,但是有空指针。
5.在sizeof中的含义不同,引用的结果是引用的类型的大小,而指针则是地址的大小(4/8)
6.引用自加增加的是实体,而指针自加则是向后偏移一个该类型的大小
7.有多级指针,但是没有多级引用。
8.访问实体方式不同,指针需要显式解引用,而引用则是编译器自己去解引用
9.引用比指针用起来相对更安全
7.内联函数
以 inline 关键字修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有了函数调用时建立栈帧的开销,内联函数能提升程序运行的效率
比如对于某些体量很小,但是我们频繁调用的函数,如我们排序中的swap函数,每一次调用都要建立栈帧,频繁的创建和销毁函数栈帧就大大增加了系统的开销。
在C语言我们是怎么优化这种开销的呢?C语言我们讲过define定义的宏,宏能实现简单的逻辑,而且由于红石进行替换的,所以不会有函数创建和销毁栈帧的开销。但是宏有两个缺点,一个是无法调试,另一个则是没有类型检查。
于是C++就对其进行了优化,用内联函数和替换掉了C语言的宏,内联函数的展开式在编译期间展开的,所以我们是能够对其进行调试的,而且内联函数也是函数的一种,有类型检查。
内联函数要怎么写呢?
我们就正常写一个函数,然后在函数的前面加上inline,
inline void swap(int& x, int& y)
{
int tmp = x;
x = y;
y = tmp;
}
我们可以在release版本下的反汇编中观察
我们可以发现在汇编指令中并没有call swap函数的操作,而在debug版本的汇编代码是这样的
我们可以看到在debug版本中还是去调用了swap函数的,这是为什么呢?因为在debug版本默认是不会展开内联函数的,而在release版本会直接展开,因为在debug版本要支持调试,编译器默认不会对代码进行优化,我们也可以设置在debug版本也展开内联函数。
首先我们点开工程的属性,找到C/C++的常规属性,然后把调试信息格式改成程序数据库
然后在下面的优化中把内联函数扩展从默认修改为只适用于 inline
这时候我们再编译转到反汇编就能看到内联函数的展开了。记得在测试完之后把属性还原回来。
我们可以看到inline是支持调试的,而内联函数也想函数一样会进行类型检查,所以他解决了宏的缺点。
内联函数这是建议编译器展开代码,但是最终决定权还是在编译器。并不是说任何一个函数加上inline之后就都会变成内联函数在调用的地方展开,比如递归函数或者一些比较长的函数,不要去是不会将他们变成内联函数的。inline 相对于相当于发送了一个请求,但是编译器可以忽略这个请求。
我说明函数长了之后就不能展开了呢?意味着会引起代码膨胀。如果这个长函数在多个地方被多次调用,展开的代码量或者编译指令两会很大。而代码量的大小影响了可执行程序的大小(.exe),也就是程序安装包的大小。
内联函数不支持声明和定义分离
函数声明和定义分离是什么呢?就是函数的声明写在.h文件中,函数的实现写在一个.cpp文件中,这时候我们在我们的main函数的文件中是展不开内联函数的。这是为什么呢?
我们在C语言中讲过程序的预处理和编译,我们假设声明与定义分离,这时候我们就有两个源文件,main.cpp 和 函数实现的文件 func.cpp,预处理阶段头文件被展开到了两个源文件中,我们知道,内联函数是用来展开的,而不会存在运行期间被调用的情况,那么在编译期间比以前还有必要把内联函数的地址写进符号表中吗?注意,只要你在函数前面加了inline修饰,不管最终编译器通不通过他的请求,都不会把这个函数的地址加载到符号表中。我们知道,在链接之前,这两个文件是不会有任何交互的,而我们的函数实现是放在func.cpp中的,在我们的函数被调用的文件 main.cpp中是找不到函数的实现的,既然找不到函数的实现,那么这个内联函数就无法展开,那么编译器只能在链接的时候去找,但是因为他是inline修饰的,函数不进符号表,编译器在符号表中也找不到,这时候就会报错,报的错是无法解析的外部符号,也就是找不到函数的定义。
导致链接错误的原因就是他不进符号表。
那么我们怎么解决呢?
既然声明和定义分离不行,我们就不要分离呗。内联函数的定义我们直接在头文件中实现,这样就不会出现链接错误了。
C++11小语法(语法糖)
1.auto:自动推导类型
2.typeid 拿到一个变量的类型的字符串
auto能够根据上下文环境自动推导出变量的类型,比如我们可以这样定义一个变量:
int a = 1;
auto b = 5;
auto c = a + b;
auto d = 1.23;
我们如何验证b、c、d的类型呢。 我们可以使用typeid来得到他们的类型,它的用法是
typeid(变量名).name()
这样就能得到变量的类型的字符串,那么这时候我们就可以将上面的bcd的类型打印出来
cout<<typeid(b).name()<<endl;
cout << typeid(c).name() << endl;
cout << typeid(d).name() << endl;
我们发现b 、c、d的类型都被推导出来了。
除了这样用,我们还可以在 auto后面加 & 和* 来指定auto是引用或者指针。
int a = 10;
auto& ra = a;
auto* pa = &a;
cout << typeid(ra).name() << endl;
cout << typeid(pa).name() << endl;
这时候我们就能发现,语法上引用是对变量的别名,所以它的类型其实就是引用的实体的类型
那么auto用在哪里呢?
当我们C++学到后面学到容器和迭代器的时候,类型名是很长的,用auto就很方便。
使用auto的时候我们要注意以下的几个问题
(1)我们也可以像以前使用内置类型一样使用auto在一行定义多个变量,但是这些变量的类型必须是一样的。
auto a = 10, b = 20,c = 30;
//不能用auto在一行都有不同的类型的变量
auto d = 10, e = 1.2;//编译器会报错
(2)auto不能直接用来声明数组
//编译器会报错
auto a[10];//错误的
auto b[5] = { 1,2,3,4,7 };//错误的
(3)auto不能做参数类型,因为编译器无法对参数的类型进行推导
3.范围for
在C语言中我们遍历数组要先求出数组长度,然后用一个变量做下标去遍历访问,每次求数组长度都要写 sizeof(a)/sizeof(a[0]),这样很麻烦。而在C++11中,我们就可以用一种很方便的方式来遍历数组 ,也就是范围for
for (auto e : a)
{
cout << e << " ";
}
我们用一个 auto + 一个用来存储数组元素的变量 + : +数组名,就能遍历数组,每次都把数组元素拷贝到 变量中 。这个变量是不需要我们在前面提前定义的。当然,我们也可以把这里的 auto 换成数组元素类型
for (int e : a)
{
cout << e << " ";
}
但是没必要,我们用auto就很方便,不管打印什么类型的数组,auto都能推导出他的数据类型,因为他已经在上文定义了。
上面的写法我们只是把数组元素拷贝到了变量 e 中,我们修改 e 是无法改变数组的数据的,就跟函数的传值类形参一样是实参的一份拷贝,如果我们想要修改数组的值该如何遍历呢? 很简单,把e的类型改成引用就好了
for (auto& e : a)
{
e *= 2;
}
这时候我们再把数组打印出来,就能发现数组中的数据已经修改了
要注意,这里不能用指针类型,因为数组的数据类型是 int ,我们用 int 或者 int& 都可以接收 int类型的值,但是却不能用 int * 来接受 int 类型的数据。类型不匹配
还要注意的是,这种用法不能在函数中去用,因为数组传参本质上传的是数组的首元素地址,无法实现遍历。
3.nullptr
在C++98中,NULL字面量直接被定义成了 0 ,甚至没有把 0 强制转换成指针类型
注意看,C++中是直接被定义成 0 的。那么这时候就会导致一个问题。如果有以下两个函数重载
void func(int a)
{
cout << "int" << endl;
}
void func(int* pa)
{
cout << "int*"<<endl;
}
那么当传NULL调用func函数时,他调用的是哪个函数呢? 显而易见调用的是第一个,因为NULL字面量就是 0 ,而0就是int类型的。
于是C++11中就增加了一个新的关键字 nullptr用来表示空指针,而这个 nullptr 就等价于((void*)0),而不是单纯的 0了,强制转换成指针了。我们可以用sizeof来打印他们的带下哦来验证一下
在 x64环境下打印的,所以它们的大小是 8个字节。
以后在C++编程时,我们都是用nullptr来表示空指针了。
在这篇文章中我们讲的基本都是 C++ 对于 C 语言的不足之处的优化,但是C++更重要的还是增加了面向对象的特性,这才是C++最重要的地方也是它独立于C语言的原因。在后面的博客中,我们将详细讲解C++的面向对象特性。