C++简介
C语言是结构化和模块化的语言,适合处理较小规模的程序。对于复杂的问题,规模较大的程序,需要高度的抽象和建模时,C语言则不合适。为了解决软件危机, 20世纪80年代, 计算机界提出了OOP(object oriented programming:面向对象)思想,支持面向对象的程序设计语言应运而生。
1982年,Bjarne Stroustrup博士在C语言的基础上引入并扩充了面向对象的概念,发明了一种新的程序语言。为了表达该语言与C语言的渊源关系,命名为C++。因此:C++是基于C语言而产生的,它既可以进行C语言的过程化程序设计,又可以进行以抽象数据类型为特点的基于对象的程序设计,还可以进行面向对象的程序设计。
C++是在C的基础之上,容纳进去了面向对象编程思想,并增加了许多有用的库,以及编程范式等。熟悉C语言之后,对C++学习有一定的帮助。下面是我们本篇博客的目标:
1. 补充C语言语法的不足,以及C++是如何对C语言设计不合理的地方进行优化的,比如:作用域方面、IO方面、函数方面、指针方面、宏方面等。
2. 为后续类和对象学习打基础。
1. C++关键字(C++98)
C++总计63个关键字,C语言32个关键字
下面我们看一下C++有多少关键字,不对关键字进行具体的讲解。后面我们学到以后再细讲。
2. 命名空间
在C/C++中,变量、函数和后面要学到的类都是大量存在的,这些变量、函数和类的名称将都存在于全局作用域中,可能会导致很多冲突。使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突或名字污染,namespace
关键字的出现就是针对这种问题的。
下面我们来举个栗子:
我们可以发现我们定义的变量和库中的随机数生成函数重名导致了报错,那么我们应该如何解决这一问题呢?这里就必须引入定义命名空间的关键字:namespace
。
2.1 命名空间的定义
定义命名空间,需要使用到namespace
关键字,后面跟命名空间的名字,然后接一对 {} 即可,{} 中即为命名空间的成员。
这里我们需要注意几点:
- 命名空间是自己随意取的,但一般开发中是用项目名字做命名空间名。
- 命名空间是可以嵌套的
- 同一个工程中允许存在多个相同名称的命名空间,编译器最后会合成同一个命名空间中。
- 命名空间域,只影响使用,不影响生命周期
当我们使用命名空间后,上面的问题就可以得到很好的解决。当然我们也可以使用命名空间嵌套的方式来定义:
namespace AQueue
{
namespace A
{
struct Node
{
struct Node* next;
int val;
};
struct Queue
{
struct Node* head;
struct Node* tail;
};
int min = 0;
}
}
2.1 命名空间的使用
命名空间的使用有三种方式:
(1)加命名空间名称及作用域限定符
命名空间名称呢其实就是我们在定义命名空间时,namespace
后面跟的那个名称,而作用域限定符呢就是由两个冒号构成的::
,具体是如何使用呢我们可以举例来示范一下:
namespace AQueue
{
struct Node
{
struct Node* next;
int val;
};
struct Queue
{
struct Node* head;
struct Node* tail;
};
int cpp = 0;
}
int main()
{
struct AQueue::Node n;
cout << AQueue::cpp << endl;
return 0;
}
(2)使用using将命名空间中某个成员引入
namespace AQueue
{
struct Node
{
struct Node* next;
int val;
};
struct Queue
{
struct Node* head;
struct Node* tail;
};
int cpp = 0;
}
using AQueue::Node;
using AQueue::cpp;
int main()
{
struct Node n;
n.val = 100;
cout << cpp << endl;
return 0;
}
(3)使用using namespace
命名空间名称引入
namespace AQueue
{
struct Node
{
struct Node* next;
int val;
};
struct Queue
{
struct Node* head;
struct Node* tail;
};
int cpp = 0;
}
using namespace AQueue;
int main()
{
struct Node n;
n.val = 100;
cout << cpp << endl;
return 0;
}
那么这种方式是不是和我们的C++中经常写的一句代码using namespace std;
特别类似呢?没错,std就是C++标准库的命名空间名,C++将标准库的定义实现都放到这个命名空间中,我们通过这种方式将它直接展开,使用起来就更加方便了。
3. C++输入&输出
C++的输入输出分别使用cin
和cout
,我们在使用C++的标准输入输出流时,需要注意一些东西:
- 使用cout标准输出对象(控制台)和cin标准输入对象(键盘)时,必须包含< iostream>头文件以及按命名空间使用方法使用std。
- cout和cin是全局的流对象,endl是特殊的C++符号,表示换行输出, 他们都包含在包含
< iostream >
头文件中。<<
是流插入运算符,>>
是流提取运算符。- 使用C++输入输出更方便,不需要像printf/scanf输入输出时那样,需要手动控制格式。C++的输入输出可以自动识别变量类型。
- 实际上cout和cin分别是ostream和istream类型的对象,>>和<<也涉及运算符重载等知识,这些知识我们我们后续才会学习,所以我们这里只是简单学习他们的使用。后面我们还有一个章节更深入的学习IO流用法及原理。
当我们使用标准输入输出流时,是有三种方式的。
(1)指定命名空间访问
int main()
{
int n = 100;
std::cout << n << std::endl;
return 0;
}
(2)常用部分展开
using std::cin;
using std::cout;
using std::endl;
int main()
{
int n = 100;
cout << n << endl;
return 0;
}
(3)全局展开
using namespace std;
int main()
{
int n = 100;
cout << n << endl;
return 0;
}
std是C++标准库的命名空间,如何展开std使用更合理呢?
- 在日常练习中,建议直接using namespace std即可,这样就很方便。
- using namespace std展开,标准库就全部暴露出来了,如果我们定义跟库重名的类型/对象/函数,就存在冲突问题。该问题在日常练习中很少出现,但是项目开发中代码较多、规模大,就很容易出现。所以建议在项目开发中使用,像std::cout这样使用时指定命名空间 +using std::cout展开常用的库对象/类型等方式。
4. 缺省参数
4.1 缺省参数的概念
缺省参数
是声明或定义函数时为函数的参数指定一个缺省值。在调用该函数时,如果没有指定实参则采用该形参的缺省值,否则使用指定的实参。
这里我们同样来举个栗子:
这里我们可以看到如果没有参数传入就默认使用的是缺省值,否则使用传入的值。
4.2 缺省参数的分类
(1)全缺省参数
void Func(int a = 100, int b = 200, int c = 300)
{
cout << "a = " << a << " " << "b = " << b << " " << "c = " << c << endl;
}
(2)半缺省参数
void Func(int a, int b = 2, int c = 3)
{
cout << "a = " << a << " " << "b = " << b << " " << "c = " << c << endl;
}
💕 注意:
- 半缺省参数必须从右往左依次来给出,不能间隔着给
- 缺省参数不能在函数声明和定义中同时出现(如果声明与定义位置同时出现,恰巧两个位置提供的值不同,那编译器就无法确定到底该用哪个缺省值。)
- 缺省值必须是常量或者全局变量
- C语言不支持(编译器不支持)
5. 函数重载
5.1 函数重载的概念
💕 函数重载: 是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表(参数个数 或 类型 或 类型顺序)不同,常用来处理实现功能类似数据类型不同的问题。
下面我们来看一下函数重载的三种情况:
(1) 参数类型不同:
int Add(int left, int right)
{
cout << "int Add(int left, int right)" << endl;
return left + right;
}
double Add(double left, double right)
{
cout << "double Add(double left, double right)" << endl;
return left + right;
}
(2) 参数个数不同:
void f(int a)
{
cout << "f(int a)" << endl;
}
void f(int a,double b)
{
cout << "f(int a,double b)" << endl;
}
(3) 参数类型顺序不同:
void func(int a, char b)
{
cout << "func(int a,char b)" << endl;
}
void func(char b, int a)
{
cout << "func(char b, int a)" << endl;
}
5.2 函数重载的原理
为什么C++支持函数重载,而C语言不支持函数重载呢?
在C/C++中,一个程序要运行起来,需要经历以下几个阶段:预处理、编译、汇编、链接
。实际项目通常是由多个头文件和多个源文件构成,例如,【当前a.cpp中调用了b.cpp中定义的Add函数时】,编译后链接前,a.obj目标文件中没有Add的函数地址,因为Add是在b.cpp中定义的,所以Add的地址在b.obj中。所以在链接阶段就可以处理这种问题,链接器看到a.obj调用Add,但是没有Add的地址,就会到b.obj的符号表中找Add的地址,然后链接到一起。
那么链接时,面对Add函数,链接接器会使用哪个名字去找呢?这里每个编译器都有自己的 函数名修饰规则
。由于Windows下vs的修饰规则过于复杂,而Linux
下gcc和g++的修饰规则简单易懂,下面我们使用了gcc和g++演示一下这个修饰后的名字。
💕 当使用C语言编译器gcc编译后的结果:
在Linux下,采用gcc编译完成后,函数名字的修饰没有发生改变。
💕 当使用C++编译器gcc编译后的结果:
在Linux下,采用g++编译完成后,函数名字的修饰发生改变,编译器将函数参数类型信息添加到修改后的名字中。
当然在Windows下也有vs编译器也有他专门的一套函数名字修饰规则,这里我们可以简单看一下:
通过这里就理解了C语言没办法支持重载,因为同名函数没办法区分。而C++是通过函数修饰规则来区分,只要参数不同,修饰出来的名字就不一样,就支持了重载。
💖 这里我们需要注意一点: 如果两个函数函数名和参数是一样的,返回值不同,是不能构成重载的,因为调用时编译器没办法区分。
6. 引用
6.1 引用的概念
引用不是新定义一个变量,而是
给已存在变量取了一个别名
,编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。
基本用法: 类型& 引用变量名(对象名) = 引用实体;
下面我们来举个例子:
这里我们可以看到使用引用给变量i取别名为k后,k和i使用的是同一块空间。
6.2 引用特性
- 引用在定义时必须初始化
- 一个变量可以有多个引用
- 引用一旦引用一个实体,再不能引用其他实体
6.3 常引用
常引用是指在引用类型的前面加const
关键字,常引用和const修饰变量一样,被const修饰的引用是不能修改引用变量的值的。
void TestConstRef()
{
const int a = 10;
//int& ra = a; // 该语句编译时会出错,a为常量
const int& ra = a;
// int& b = 10; // 该语句编译时会出错,b为常量
const int& b = 10;
double d = 12.34;
//int& rd = d; // 该语句编译时会出错,类型不同
const int& rd = d;
}
这里我们需要注意一个问题,那就是引用的权限问题,当我们使用指针和引用的时候,赋值/初始化
权限可以缩小,但是不能放大。当然,这里的权限指的是读和写的权限,下面我们来举一个例子:
(1)权限保持
void test()
{
// 权限保持
const int c = 2;
const int& d = c;
const int* p1 = NULL;
const int* p2 = p1;
}
这里我们可以发现并没有产生报错,因为c是被const修饰过的,所以c只有可读的权限,当我们使用可读权限的d引用给c取别名时,没有任何的意外。同理,指针也是如此,他们都属于权限的平移。
(2)权限缩小
void test()
{
// 权限缩小
int x = 1;
const int& y = x;
int* p3 = NULL;
const int* p4 = p3;
}
这里如果将具有可读和可写权限的变量x使用只具有可读权限的引用取别名后,也并没有太大的问题,这就属于权限的缩小。
(3)权限放大
void test()
{
//权限放大
const int c = 2;
int& d = c;
const int* p1 = NULL;
int* p2 = p1;
}
最后,当一个变量只具有可读权限,但是我们却使用具有可读和可写权限的引用去给其取别名时,就会报错,因为这属于权限的放大,语法不支持。
(4)对不同类型的变量进行引用
void test()
{
double d = 3.14;
int& i = d;
const int& j = d;
}
这里我们发现,如果直接使用不同的类型变量进行引用的话就会报错,但是如果我们在引用变量前面使用const进行修饰的话就不会出现任何的问题,这是因为,对d进行引用时,d会先被转换成int类型,然后赋值给一个临时变量,由于临时变量具有常性,所以我们只能够用const去修饰int&,才能保证权限不会被放大。
这里我们还需要注意一点:
当我们想要对一个常量进行引用时,也需要使用const来进行修饰,因为常量也是只具有可读的,只有使用const修饰后才能保证权限不被放大。
6.4 引用的使用场景
(1) 引用做参数
当我们使用引用做函数参数的时候,一般是为了保证形参的改变能够影响实参,以前我们一般都是使用指针的方式来保证形参的改变能够影响实参。现在我们可以使用引用的方式来代替指针。
void swap(int& r1, int& r2)
{
int tmp = r1;
r1 = r2;
r2 = tmp;
}
下面我们来谈一谈引用做函数参数的优点:
- 减少空间浪费,使用引用做函数参数时,因为引用就是实参的别名,所以在传参的时候就可以直接对实参进行操作,从而避免了多余空间的开辟。
- 使用引用可以避免指针的误操作,我们知道指针有时候用起来非常的麻烦,而且还容易误操作,当我们需要使用输出型参数时,可以使用引用来代替指针。尤其是在一些需要传递二级指针的情况下,使用引用会方便很多。
虽然引用可以取代指针的大部分情况,但是引用一旦引用一个实体,就不再引用其他实体,所以引用的出现是不能完全代替指针的,只能简化大部分需要使用指针的情况。
如果没有用函数参数作为临时变量的需要,或需要用引用来修改原变量的值,要尽可能地使用常引用
作为参数类型。这样可以防止我们在函数内不小心修改参数从而可能导致的一系列问题。
(2) 引用做返回值
这里我们先通过以下代码来回顾一下函数栈帧的创建和销毁的过程:
int Count()
{
int n = 0;
n++;
return n;
}
int main()
{
int ret = Count();
return 0;
}
因为n是在栈区上创建的,所以当Count函数调用结束后, n这块空间就会被销毁并还给操作系统,我们想要得到n的值,就必须通过一个临时变量来将值带回main函数的函数栈帧中,当然了,这个临时变量可以是寄存器,也可以是在main函数栈帧中的一块空间。
下面我们来看一下下面的代码:
int& Count()
{
static int n = 0;
n++;
return n;
}
int main()
{
int ret = Count();
return 0;
}
因为这里的n使用了static来修饰,所以它是在在静态区中创建的,而静态区中创建的变量并不会随着Count函数栈帧的销毁而释放,所以这里Count函数的返回值使用引用,我们可以使用返回引用来访问n,这样就不需要在创建一个临时的空间来保存n的值了。
下面我们来看一种错误地使用引用作为返回值的代码:
这里的c是在Add函数中的局部变量,当函数调用结束后就会被释放,但是这里却返回了它的引用,相当于函数调用结束后将c的那块不属于我们的空间的内容引用取别名为ret,当然了函数栈帧销毁后不代表那快空间的数据已经被置为随机值或者被覆盖掉了,当该空间没有被编译器分配给其他函数或者变量使用时,这块空间的数据就没有被覆盖,我们还能拿到原来的数据,但是如果这块空间已经被分配给其他的函数或者变量使用时,他的数据就已经被覆盖掉了。
所以,如果函数返回时,出了函数作用域,如果返回对象还在(还没还给操作系统),则可以使用引用返回,如果已经还给了操作系统了,就必须使用传值返回。
使用引用做返回值的优点:
- 减少一次数据拷贝,节省空间,提高效率。
- 直接返回变量本身,从而可以通过返回值修改变量。
6.5 传值,传引用的效率比较
以值作为参数或者返回值类型,在传参和返回期间,函数不会直接传递实参或者将变量本身直接返回,而是传递实参或者返回变量的一份临时的拷贝,因此用值作为参数或者返回值类型,效率是非常低下的,尤其是当参数或者返回值类型非常大时,效率就更低。
💕 值和引用的作为返回值类型的性能比较
通过上述代码的比较,发现传值和指针在作为传参以及返回值类型上效率相差很大。
6.6 引用和指针的区别
- 在语法概念上引用就是一个别名,没有独立空间,和其引用实体共用同一块空间。
- 在底层实现上实际是有空间的,因为引用是按照指针方式来实现的。
💕 下面我们来看下引用和指针的汇编代码对比:
int main()
{
int a = 10;
int& ra = a;
ra = 20;
int* pa = &a;
*pa = 20;
return 0;
}
引用和指针的不同点:
- 引用概念上定义一个变量的别名,指针存储一个变量地址
- 引用在定义时
必须初始化
,指针没有要求- 引用在初始化时引用一个实体后,就
不能再引用其他实体
,而指针可以在任何时候指向任何
一个同类型实体没有NULL引用
,但有NULL指针在sizeof中含义不同:引用结果为引用类型的大小
,但指针始终是地址空间所占字节个数(32
位平台下占4个字节)- 引用自加即引用的实体增加1,指针自加即指针向后偏移一个类型的大小
有多级指针,但是没有多级引用
- 访问实体方式不同,指针需要显式解引用,引用编译器自己处理
引用比指针使用起来相对更安全
7. 内联函数
7.1 内联函数的概念
以
inline
修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数调用建立栈帧的开销,内联函数提升程序运行的效率。
我们知道,函数调用会建立栈帧,而如果要是需要频繁的调用一个特别小的函数(十来行代码的函数)时,必然会造成效率的降低,在C语言中,我们可以使用宏函数来解决这个问题,但是宏却有着很多的缺点:
- 不能调试
- 没有类型安全的检查
- 有些场景下非常复杂,容易出错,不容易掌握
💕 查看内联函数
inline int Add(int left, int right)
{
return left + right;
}
int main()
{
int ret = Add(1, 2);
return 0;
}
在debug模式下,需要对编译器进行设置,否则不会展开(因为debug模式下,编译器默认不会对代码进行优化,以下给出vs2019的设置方式)
完成以上设置后,我们进入调试阶段,然后右键单击反汇编转入汇编代码。
7.2 内联函数的特性:
- inline是一种以空间换时间的做法,如果编译器将函数当成内联函数处理,在编译阶段,会用函数体替换函数调用,缺陷:可能会使目标文件变大,优势:少了调用开销,提高程序运行效率。
- inline对于编译器而言只是一个建议,不同编译器关于inline实现机制可能不同,一般建议:将函数规模较小(即函数不是很长,具体没有准确的说法,取决于编译器内部实现)、不是递归、且频繁调用的函数采用inline修饰,否则编译器会忽略inline特性。
- inline不建议声明和定义分离,分离会导致链接错误。因为inline被展开,就没有函数地址
了,链接就会找不到。
💕 【面试题】
宏的优缺点?
优点:
1.增强代码的复用性。
2.提高性能。
缺点:
1.不方便调试宏。(因为预编译阶段进行了替换)
2.导致代码可读性差,可维护性差,容易误用。
3.没有类型安全的检查 。
C++有哪些技术替代宏?
1. 常量定义 换用const enum
2. 短小函数定义 换用内联函数
8. auto关键字(C++11)
在早期C/C++中auto的含义是:使用auto修饰的变量,是具有自动存储器的局部变量,但遗憾的
是一直没有人去使用它。
C++11中,标准委员会赋予了auto全新的含义即:auto不再是一个存储类型指示符,而是作为一
个新的类型指示符来指示编译器,auto声明的变量必须由编译器在编译时期推导而得。
这里我们需要注意一点:
使用
auto
定义变量时必须对其进行初始化,在编译阶段编译器需要根据初始化表达式来推导auto
的实际类型。因此auto并非是一种“类型”的声明,而是一个类型声明时的“占位符”,编译器在编
译期会将auto替换为变量实际的类型。
8.1 auto的使用规则
(1)auto与指针和引用结合起来使用
用auto声明指针类型时,用auto和auto*没有任何区别,但用auto声明引用类型时则必须
加&
int main()
{
int x = 10;
auto a = &x;
auto* b = &x;
auto& c = x;
cout << typeid(a).name() << endl;
cout << typeid(b).name() << endl;
cout << typeid(c).name() << endl;
*a = 20;
*b = 30;
c = 40;
return 0;
}
(2) 在同一行定义多个变量
void TestAuto()
{
auto a = 1, b = 2;
auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
}
8.2 auto不能推导的场景
(1)auto不能作为函数的参数
// 此处代码编译失败,auto不能作为形参类型,因为编译器无法对a的实际类型进行推导
void TestAuto(auto a)
{}
(2)auto不能直接用来声明数组
void TestAuto()
{
int a[] = {1,2,3};
auto b[] = {4,5,6};
}
(3) 为了避免与C++98中的auto发生混淆,C++11只保留了auto作为类型指示符的用法。
(4)auto在实际中最常见的优势用法就是跟以后会讲到的C++11提供的新式for循环,还有lambda表达式等进行配合使用。
9. 基于范围的for循环(C++11)
9.1 范围for的语法
在C++98中如果要遍历一个数组,可以按照以下方式进行:
int main()
{
int array[] = { 1, 2, 3, 4, 5,6,6,4 };
for (int i = 0; i < sizeof(array) / sizeof(int); ++i)
{
cout << array[i] << " ";
}
cout << endl;
return 0;
}
对于一个有范围的集合而言,由程序员来说明循环的范围是多余的,有时候还会容易犯错误。因
此C++11中引入了基于范围的for循环。for循环后的括号由冒号“ :”分为两部分: 第一部分是范 围内用于迭代的变量
,第二部分则表示被迭代的范围
。
int main()
{
// 范围for -- 语法糖
// 自动依次取数组中数据赋值给x对象,自动判断结束
int array[] = { 1, 2, 3, 4, 5,6,6,4 };
//for (int x : array)
for (auto x : array)
{
cout << x << " ";
}
for (auto& e : array)
{
e *= 2;
cout << e << " ";
}
cout << endl;
return 0;
}
注意: 与普通循环类似,可以用continue来结束本次循环,也可以用break来跳出整个循环。
9.1 范围for的使用条件
(1)for循环迭代的范围必须是确定的
对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin
和end
的方法,begin和end就是for循环迭代的范围。
💕 注意: 以下代码就有问题,因为for的范围不确定
void TestFor(int array[])
{
for(auto& e : array)
cout<< e <<endl;
}
(2)迭代的对象要实现++和==的操作。(关于迭代器这个问题,以后会讲,现在提一下,没办法讲清楚,现在大家了解一下就可以了)
10. 指针空值nullptr(C++11)
在良好的C/C++编程习惯中,声明一个变量时最好给该变量一个合适的初始值,否则可能会出现
不可预料的错误,比如未初始化的指针。如果一个指针没有合法的指向,我们基本都是按照如下
方式对其进行初始化:
void TestPtr()
{
int* p1 = NULL;
int* p2 = 0;
// ……
}
NULL
实际是一个宏,在传统的C头文件(stddef.h)中,可以看到如下代码:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
可以看到,NULL可能被定义为字面常量0,或者被定义为无类型指针(void*)
的常量。不论采取何
种定义,在使用空值的指针时,都不可避免的会遇到一些麻烦,比如:
void f(int)
{
cout<<"f(int)"<<endl;
}
void f(int*)
{
cout<<"f(int*)"<<endl;
}
int main()
{
f(0);
f(NULL);
f((int*)NULL);
return 0;
}
程序本意是想通过f(NULL)调用指针版本的f(int*)函数,但是由于NULL被定义成0,因此与程序的
初衷相悖。
在C++98中,字面常量0既可以是一个整形数字,也可以是无类型的指针(void)常量,但是编译器默认情况下将其看成是一个整形常量,如果要将其按照指针方式来使用,必须对其进行强转(void)0。
💕 注意:
- 在使用nullptr表示指针空值时,不需要包含头文件,因为nullptr是C++11作为新关键字引入
的。- 在C++11中,sizeof(nullptr) 与 sizeof((void*)0)所占的字节数相同。
- 为了提高代码的健壮性,在后续表示指针空值时建议最好使用nullptr。