C++入门基础
1、C++关键字
C++一共有63个关键字,这其中也包括有一些C语言的关键字,比如:if、for、int、long等等,C++和C语言很多地方是密切相关的。这里只是对关键字有一个了解,具体的用法在后面会深入的学习。
2、命名空间
在C语言中,我们在写代码的时候总是会遇到一些命名冲突的问题,比如说如下代码:
从图中我们可以看出,如果我们想使用scanf作为变量名,那么在输入的时候编译器就会报错,这是因为编译器在编译的时候采用的就近原则,它会认为scanf是一个变量而不是输入函数,导致编译的时候出现错误。
那么C++中的命名空间就可以很好的解决这个问题。
C++中命名空间的关键字是namespace,它其实就是一个命名域,其目的就是避免命名冲突或者命名污染。
使用方法:关键字namespace + 命名空间的名字 + {}
{}内就是命名空间的成员。
命名空间的定义
我们可以从工程的角度来解释命名空间的用法:
(1)一般的命名空间
在一般的工程或者项目中,会有不同的分工合作,那么就会不可避免的有一些函数或者变量名是相同的。这个时候我们就可以通过命名空间来将不同小组的命名区分开。
namespace N1
{
//命名空间中既可以定义变量,也可以定义函数
int a;
double b;
int Add(int left, int right)
{
return left + right;
}
}
namespace N2
{
double a;
char b;
int Mul(int left, int right)
{
return left * right;
}
}
从上面的代码中可以看到,N1和N2两个空间都有a变量和b变量,而在使用的时候是不会报错的(对于命名空间的使用以及输入输出在下面会进行讲解,这里只需要看到,对于两个相同的命名是不会出错的)。
(2) 嵌套的命名空间
在工程或者项目中,会有小组的分工;而在这些小组之中也会有很多的成员,每个人做着自己的工作,那么在命名的时候我们就可以在小组的命名空间中再嵌套一个命名空间,作为自己工作的命名空间。
namespace N2 //相当于小组的命名空间
{
int a;
int b;
int Add(int left, int right)
{
return left + right;
}
namespace N3 //相当于组内成员的命名空间
{
double a;
char b;
int Sub(int left, int right)
{
return left - right;
}
}
}
在N2的命名空间中,有a和b两个变量,而在N2中嵌套的N3也有a和b两个变量,在使用中我们通过不同命名空间的调用使用是不会报错的(下图中的两个冒号是对命名空间的调用,在接下来进行讲解)。
(3) 允许相同名称的命名空间同时存在
在一个工程或者项目中,可能会有相同作用的命名空间,那么不同的小组可以用相同名称命名空间,在最后汇总编译的时候,编译器会把相同名称的空间整合在一起。
注意:在同一个命名空间中不能定义相同的变量
我们可以看到,命名空间相当于定义了一个新的作用域,用来防止命名冲突的问题,这其中的所有内容都仅限于该命名空间中。
命名空间的使用
学会了如何定义命名空间,那么该怎样使用呢?
给定一个Test命名空间:
namespace Test
{
int a = 10;
double b = 3.14;
}
有三种方法可以使用:
(1)加命名空间名称及作用域限定符
通过加命名空间名称Test和作用域限定符“::”可以使用Test命名空间中的变量。
(2)使用using namespace将命名空间名称引入
通过using namespace将命名空间在引入,这样做就是把命名空间在使用之前展开,就和包含头文件一样。那么这样做就会产生一个问题,如果在其他函数中或者全局变量中定义了同名函数,那么又会造成命名冲突,这就有悖于命名空间的初衷。因此这样做是不太标准的,在练习中我们可以这样子为了方便,但是更加规范的还是第一种,也是我们在工程中比较常用到的。
(3)使用using关键字将命名空间中成员引入
通过using关键字,将需要用到的成员单独引入,这样可以一定程度上避免第二种使用带来的命名冲突问题。
3、c++输入和输出
在命名空间的示例中,我们在程序中可以看到用cout实现输出,那么这里就正式介绍C++的标准输入cin和标准输出cout。
在使用cin和cout时,必须包含头文件< iostream > 以及std标准命名空间。
#include <iostream>
using namespace std;
int main()
{
int a;
double b;
char c;
//输入
cout << "输入" << endl;
cin >> a;
cin >> b >> c;
//输出
cout << "输出" << endl;
cout << a << endl;
cout << b << " " << c << endl;
return 0;
}
先从输入cin来看,cin表示标准输入,后面的">>"符号表示的是流向的意思(这个符号其实是重载了,具体原因后面再进行介绍,这里先做了解),“>>”符号之后是输入的变量。整体意思就是从标准输入流向了输入变量。
输出cout表示标准输出,后面的"<<“同样也表示的是流向的意思,”<<"符号之后是输出的变量,最后的endl表示的是换行的意思。整体意思就是换行符endl流向了输出变量,输出变量的值最后流到了标准输出。
细心的人可以发现,C++中的输入输出不需要指定数据的格式,这是非常方便的。
那么学习了cin和cout就用不到scanf和printf了吗,这种想法是错误的,这两种在实际中都很实用,只是看运用的场景是什么。下面的一个例子就很好的说明了这个问题:
同样的输出结果,用printf显然要比cout方便很多,也就是在格式化的输入输出中,scanf和printf要更加方便,因此在不同的场景下选择不同的输入输出方式是最好的。
4、缺省参数
缺省参数定义
C++中的缺省参数,就像是现实中的备胎一样。
缺省参数是在声明或者定义函数时,为函数的参数指定一个默认值。那备胎在这里怎么解释呢?就是说,如果在调用函数的时候没有传实参过来就采用默认值,如果传实参了就用指定的参数。
void Test(int a = 0)
{
cout << a << endl;
}
int main()
{
Test();
Test(10);
return 0;
}
对于第一次调用的Test函数,没有传参数,那么就会使用默认值0;
第二次传了参数10,就会打印10;
缺省参数分类
(1)全缺省参数
就是所有的参数都带有默认值
void Test1(int a = 10, int b = 20, int c = 30)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl;
}
(2)半缺省参数
就是部分缺省,并不是全部的参数都带有默认值。这里要注意的是:半缺省参数,必须是从右往左缺省而且是连续缺省。这是因为传参的时候,是从左往后传参的。如果没有从右往左缺省或者不是连续缺省的话,传过去的参数就不知道是给谁传的。
void Test1(int a, int b = 20, int c = 30)
{
cout << "a = " << a << endl;
cout << "b = " << b << endl;
cout << "c = " << c << endl;
}
缺省参数使得函数在调用的时候更加灵活。
注意:
1.缺省参数不能在函函数的声明和定义中同时出现。
2.缺省值必须是常量或者全局变量,不能是变量,否则就失去了意义。
5、函数重载
函数重载是C++很重要的一个概念,在很多大厂的面试中也是常考的。什么是函数重载呢?
函数重载的定义
函数重载:是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数。函数重载的条件是:这些同名函数的形参列表中,参数个数或者参数类型或者参数顺序必须是不同的才可以重载。
函数重载经常用在处理功能类似但是数据类型不同的场景中。比如:写一个计算器中的加法函数,那么加法不可能只是对整数相加,一定也会有浮点数的相加,那么我们就可以通过函数重载实现,虽然看起来是同一个函数,但是重载之后可以进行多种数据类型的功能实现。
如下图:多种数据类型的相加,用的都是Add函数,虽然看起来是一个函数,但是调用的却是不同函数。
函数重载的原因
那么这里就有一个问题了,为什么C++支持重载,而C语言不支持重载呢?C++是怎么进行重载的?
在前面的博客中我们介绍过一个程序从编译到链接的全过程以及这些过程中都经历了什么,这里简单的回顾一下:
(1)在预处理(预编译)阶段,会进行头文件展开、宏替换、去掉注释、条件编译这些步骤。
(2)编译阶段:会进行语法的检查、生成汇编代码、语法和词法分析等等。
(3)汇编阶段:会形成符号表、将汇编指令转化成二进制指令等等。
(4)链接阶段:会合并段表、合并符号表以及符号的重定位。
对编译的过程了解一点的都知道,函数的链接是在生成符号表之后,通过符号表的地址去找函数的定义,来进行程序的运行。那么为什么C语言不能重载而C++可以呢?我们可以在符号表中找到答案。
在Linux环境下来更容易得到编译之后的符号表,如图所示:
上图是用C语言编译器gcc编译之后生成的符号表
可以看到Add函数和func函数在符号表中的名称和程序中的完全一样,编译器没有做任何的修饰。
而在C++编译器下是完全不同的。从上图可以看出:
int add(int a, int b, char ch)这个函数在符号表中表示为_Z3addiic
double add(double a, double b, int i)这个函数在符号表中表示为_Z3addPddi**
很明显C++的程序在编译之后对函数在符号表中进行了修饰,修饰的方法是把函数修饰为:_Z + 函数名的长度 + 函数名 + 参数类型的首字母
很明显其修饰与返回值类型无关,那么到这里也就明白了为什么C++可以重载,因为C++编译之后尽管函数名相同,但是由于参数类型、顺序、或者个数的不同,在符号表中修饰后是不同的,可以看作不同的函数;而C语言函数是没有修饰的,因此如果用同样的函数名,就会造成重定义。
同时在修饰中也可以发现是和返回值无关的,这也就是为什么重载的条件只和函数的参数有关。
extern “C”
有人可能会问,如果我用C++编写的程序想按照C语言的风格来进行编译可以吗?答案是肯定的。
我们可以在函数前面加 extern “C”,意思是告诉编译器,这个函数按照C语言的规则来编译。那么这里就要注意了,如果该函数是按照C语言的规则进行编译的,就不能进行函数重载了。
6、引用
引用的定义
引用并不是定义一个新变量,而是给已经定义过的变量取一个别名。编译器不会为引用变量开辟内存空间,它和它引用的变量共用一块内存空间。
引用的使用:类型& + 引用变量名(对象名) = 引用实体
从另一个角度也可以说明a和ra共用的是一个地址,如下图:
改变ra的同时a也就变了。这也说明了a和ra是同一个变量。
引用的特性
(1)引用在定义的时候必须初始化,因为引用是给变量取别名,不进行初始化的话,就不清楚是对谁取的别名。
(2)一个变量可以有多个引用。
int main()
{
int a = 10;
int& b = a;
int& c = b;
return 0;
}
上面的代码,a的别名除了b还有c,也就是具有两个别名。
(3)引用一旦引用了一个实体,就不能再去引用其他的实体。
常引用
常引用指的是在引用的时候加上const常量,但是这其中有很多易错点。
(1)
int main()
{
const int a = 10;
int& ra = a;//是不行的
const int& ra = a;//这样a和ra的权限才是相同的
return 0;
}
对于这个代码,编译器会报错,a是一个const常量,只读但是不能改变,而ra在引用a的时候,是可读可写的,那么相当于一个权限的放大。我自己本身都只能读,引用却是可读可写,这必然是不行的。
(2)
int main()
{
int b = 20;
const int& rb = b;
return 0;
}
这个代码是可以的,这是因为b是可读可写的,而rb是只读的,但是rb的权限是包含在b的权限中,相当于一个权限的缩小,这是没问题的。
(3)
再看最后一个代码
int main()
{
int c = 10;
double& rc = c;//这样写编译出错
const double& crc = c;//这样写是可以的
return 0;
}
double& rc = c 这样写为什么会出错呢?rc作为c的引用,两者本身的类型是不相同的,那么不相同的类型赋值就会有一个隐式类型转换,但是这个转换并不是把c直接给rc,而是有一个临时变量,这个临时变量作为转换的中介,它具有常兴,因此用double& 类型去接收是一个权限的放大。
那么用const double& 去接收才是正确的。
使用场景
(1)做参数
引用可以作为函数的参数,这样就避免了传地址,使得代码的可读性增强。
void Swap(int& ra, int& rb)
{
int tmp = ra;
ra = rb;
rb = tmp;
}
int main()
{
int a = 10;
int b = 30;
Swap(a, b);
return 0;
}
(2)做返回值
引用做返回值是有一些讲究的,有如下的代码:
int& Add(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int& ret = Add(1, 2);
Add(3, 4);
cout << "Add(1, 2)= " << ret << endl;
return 0;
}
这个代码的结果是什么呢?
答案是7而不是3。
Add在第一次调用的时候用ret作为接收,说明ret是Add返回值的引用,但是此时Add的返回值c已经出了作用域销毁了,而在第二次调用Add的时候就改变了返回值,同时也把ret改变了,但是此时ret相当于越界访问了,但是编译器没有检查出来。这也就是为什么ret的结果最后会变成7。
所以要注意:如果出了函数的作用域,返回的对象还没有还给系统,可以使用引用返回;如果已经还给系统了,必须使用传值返回
引用和指针的区别
既然引用可以代替传地址来进行传参,那么引用和指针有什么区别呢?
(1)引用在语法概念上就是给定义的变量取别名,没有独立的空间,和其引用的实体变量共用同一块空间。而指针在语法概念上是开辟出一块,用于存储变量的地址,通过访问地址来改变变量的值。
(2)引用在底层的实现上实际是有空间的,也是类似于指针存地址的方式实现的,这是从汇编的角度来看引用。
7、内联函数
大家都知道,在编译器中调用函数是需要建立栈帧的,因此会有一部分的的函数压栈的开销,那么对于一些小函数的频繁调用就会导致一些不必要的开销。
在C语言中,为了避免小函数建立栈帧,提供了宏函数支持,其在预处理阶段展开,不需要调用。
但是宏是有很多缺点的:其一是不能够进行调试;其二是写的时候比较复杂,要注意符号优先级等情况;其三是对于传递的参数没有类型检查,很容易出错。
针对宏函数的这些缺点,C++引入了内联函数的概念,内联函数通过inline关键字进行修饰,编译器会在调用内联函数的地方直接栈开,没有函数压栈的开销。
上图中可以看出,在没有使用内联函数在汇编中需要call指令调用;
在使用内联函数之后,汇编中没有用call指令进行调用,而是在对应的位置直接展开,减少了调用函数的开销。
关于内联函数,有几点需要注意:
(1)inline函数的本质上和宏函数差不多,都是在对应地方直接展开,减少开销。因此在代码很长或者有循环、递归的函数不建议使用内联函数,这样会导致代码量的急剧增加。
(2)inline对于编译器来说只是提供一个建议,编译器在编译时会自动优化,如果定义为inline函数的内部有循环或者递归,编译器会忽略内联。
(3)inline不建议声明和定义分离,因为内联函数是直接展开的,没有函数地址,如果分开的话,在链接的时候就会找不到。
8、auto关键字
auto关键字是在C++11的标准中引入的,auto具有自动推导变量类型的功能。auto关键字在使用的时候一定要初始化,因为它要根据初始化的表达式来推导实际类型。
上图中,用auto修饰的变量可以自动识别出变量类型,看起来好像没有方便很多,它真正的用途在以后会讲解。
auto的使用
(1)auto和与指针或者引用结合起来使用
有如下的代码:
int main()
{
int x = 10;
auto a = &x;
auto* b = &x;
auto& c = x;
return 0;
}
通过查看a、b、c的类型可以看出,a和b都是int的类型,说明声明指针的时候auto和auto没有区别,而auto声明引用类型的时候必须加&,否则会声明成普通变量。
(2) 在同一行使用auto定义多个变量
用auto在一行中定义多个变量的时候,这些变量只能是同一类型的,否则编译器会报错。因为在C++中,编译器只会对第一个变量进行类型推导,用推导出来的类型定义其他的变量。
如上图,定义a和b的时候是没有问题的,因为a和b都是int类型;而c和d的定义中d会被报错,这是因为此时编译器将d定义为int类型,因为c是int类型,但是d实际上是double类型,所以会出现错误。
基于范围的for循环
在auto关键字中,C++11的标准新增了一个用法,就是基于范围的for循环。在C语言中或者是C++98的标准下,我们遍历数组的方式如下:
int main()
{
int a[5] = { 1, 2, 3, 4, 5 };
//以下标访问
for (int i = 0; i < 5; i++)
{
a[i] *= 2;
}
//以指针访问
for (int* p = a; p < a + 5; p++)
{
*p *= 2;
}
return 0;
}
但是如果遍历数组的话,对于一些比较娴熟的程序员,这样写就很多余了,有时候还会出现越界的错误。那么可以通过auto的基于范围的for循环来完成遍历的操作。
for循环后的括号由冒号" :"分为两部分:前面的是范围内用于迭代的变量,后面的是迭代的范围。
注意:在基于范围的for循环中要注意,迭代的范围必须是明确的,在数组中就是第一个元素和最后一个元素的范围。
void TestFor(int array[])
{
for(auto& e : array)
cout<< e <<endl;
}
上面的代码是出错的,因为数组在形参中只是一个首元素的指针,不能代表范围,并不能使用基于范围的for循环。
10、指针空值nullptr
在C语言中,我们都知道空指针用NULL表示,NULL其实是用宏实现的,被定义为字面常量0或者是无类型指针(void*)常量。但是这样的定义会带来一些麻烦:
void f(int)
{
cout << "int" << endl;
}
void f(int*)
{
cout << "int*" << endl;
}
int main()
{
f(0);
f(NULL);
f((int*)NULL);
return 0;
}
对于这段代码,结果是什么?
可以看到,0和NULL是相同的结果,那么在这里NULL并没有当作一个指针处理,而是字面常量0,这在实际使用时就会出现一些问题。如果要按照指针方式使用,就必须对其进行强制类型转换(void*)0。
为了解决这个问题,在C++11中引入了nullptr作为空指针,它直接将空指针定义为指针类型,避免了不必要的麻烦。