1.C++的发展史
C语言诞生后,很快普及使用,但是随着编程规模增大且越来越复杂,并且需要高度的抽象和建模时,C语言的诸多短板便表现了出来,为了解决软件危机,上世纪八十年代,计算机界提出了oop(object
oriented programming:面向对象编程)思想,支持面向对象的程序设计语言应运而生。
1982年,Bjarne Stroustrup博士在C语言的基础上引入并扩充了面向对象的概念,发明了一种新的程序语言。为了表达该语言与C语言的渊源关系,命名为C++。因此:C++是基于C语言而产生的,它既可以进行C语言的过程化程序设计,又可以进行以抽象数据类型为特点的基于对象的程序设计,还可以进行面向对象的程序设计。
1979年,贝尔实验室的本贾尼等人试图分析unix内核的时候,试图将内核模块化,于是在C语言的基础上进行扩展,增加了类的机制,完成了一个可以运行的预处理程序,称之为C with classes。
语言的发展就像是练功打怪升级一样,也是逐步递进,由浅入深的过程。以下是C++的历史版本
C with classes
类及派生类、公有和私有成员、类的构造和析构、友元、内联函数、赋值运算符重载等
C++1.0 添加虚函数概念,函数和运算符重载,引用、常量等
C++2.0 更加完善支持面向对象,新增保护成员、多重继承、对象的初始化、抽象类、静态成员以
及const成员函数
C++3.0 进一步完善,引入模板,解决多重继承产生的二义性问题和相应构造和析构的处理
C++98 C++标准第一个版本,绝大多数编译器都支持,得到了国际标准化组织(ISO)和美国标准化
协会认可,以模板方式重写C++标准库,引入了STL(标准模板库)
C++03 C++标准第二个版本,语言特性无大改变,主要:修订错误、减少多异性
C++05 C++标准委员会发布了一份计数报告(Technical Report,TR1),正式更名C++0x,即:计
划在本世纪第一个10年的某个时间发布
C++11 增加了许多特性,使得C++更像一种新语言,比如:正则表达式、基于范围for循环、auto
关键字、新容器、列表初始化、标准线程库等
C++14 对C++11的扩展,主要是修复C++11中漏洞以及改进,比如:泛型的lambda表达式,
auto的返回值类型推导,二进制字面常量等
C++17 在C++11上做了一些小幅改进,增加了19个新特性,比如:static_assert()的文本信息可
选,Fold表达式用于可变的模板,if和switch语句中的初始化器等
C++20 制定ing
C++23
C++26
2.C++的重要作用
在开发语言排行榜上,C++几乎稳居前三,可见其泛用性。
(下图为23年6月的)
(下图是2024年8月的)
在工作领域,C++在以下领域有其独到的优势:
操作系统以及大型系统软件开发服务器端开发人工智能网络工具游戏开发嵌入式领域数字图像处理分布式应用移动设备在校招领域 ,不多说直接上图:
笔试题:网易笔试、迅雷笔试等等
面试题:
从校招中公司岗位的技能要求,以及学长面经总结了解到,公司在校招期间更看重学生的基础,最主要是:语言(至少掌握一门面向对象语言java/C++)、数据结构、操作系统、网络、数据库、设计模式等,而本门C++的授课内容,更注重学生的实践动手能力、工作中的应用以及笔试面试中的技巧,最后达到能够正常工作以及学习即可。
3.C++的基本语法
闲话少说,接下来直接进入C++学习。
下面是C++的关键字:
asm do if return try continue auto double inline short typedef for bool dynamic_cast int signed typeid public break else long sizeof typename throw case enum mutable static union wchar_t catch explicit namespace static_cast unsigned default char export new struct using friend class extern operator switch virtual register const false private template void true const_cast float protected this volatile while delete goto reinterpret_cast
相比C语言足足多了一倍!(C语言32个,C++63个)
但是这里先不细讲,这些关键字在以后的学习中都会学到的。
3.1 命名空间
什么是命名空间?为什么要有命名空间?
在C/C++中,变量、函数和后面要学到的类都是大量存在的,如果这些变量、函数和类的名称都存在于全局作用域中,可能会导致很多冲突。使用命名空间的目的是对标识符的名称进行本地化,以避免命名冲突或名字污染,namespace关键字的出现就是针对这种问题的。
例如:在C语言中
#include<stdio.h>#include<stdlib.h>int rand=10;int main(){printf("%d",rand);return 0;}
上面这个函数会报错:
它会告诉你,rand是一个函数名字,这里的全局变量rand有命名冲突,因为在库中有了rand这个函数,再次使用rand这个名字定义变量或者函数时,编译器会分不清你到底想使用哪个rand。
可能你觉得问题不大,大不了我换个名字就好了嘛,但是在大型工程项目中,数以MB的大小里面的变量名字可不是简简单单换个名字这么简单
C++为了解决这个问题,引出了命名空间这个玩法(C++兼容C语言的所有语法)
命名空间如何定义呢?
定义命名空间,需要使用到namespace关键字,后面跟命名空间的名字,然后接一对{}即可,{}中即为命名空间的成员
namespace N1 // N1为命名空间的名称{ // 命名空间中的内容,既可以定义变量,也可以定义函数,也可以定义结构体 int a; int rand; int Add(int left, int right) { return left + right; } struct student { char name[]; int age; }}//2. 命名空间可以嵌套namespace N2{ int a; int b; int Add(int left, int right) { return left + right; } namespace N3 { int c; int d; int Sub(int left, int right) { return left - right; } }}//3. 同一个工程中允许存在多个相同名称的命名空间,编译器最后会合成同一个命名空间中。namespace N1{ int Mul(int left, int right) { return left * right; }}
如最上面的N1,在这个代码中,rand就定义在命名空间N1中,这和库函数中定义的全局函数rand()不在同一个空间中,所以可以同时存在
命名空间定义后如何使用呢?
namespace N{ int a = 10; int b = 20; int Add(int left, int right) { return left + right; } int Sub(int left, int right) { return left - right; }}int main(){ printf("%d\n", a); // 该语句编译出错,无法识别a return 0;}
编译这个程序,会发现编译出错,编译器无法识别a,那是因为我们定义了命名空间N之后并没有使用它,命名空间的使用方法有以下三种:
1.加命名空间名称及作用域限定符:
int main(){ printf("%d\n", N::a);//::就是作用域限定符 return 0; }
2.使用using将命名空间中的成员引入:(写项目的时候使用)
using N::b;//将N::b引入全局中int main(){ printf("%d\n", N::a); printf("%d\n", b); return 0; }
3.使用using namespace命名空间名称引入(建日常使用)
using namespce N;//这里相当于把N展开了,也就是把N的成员加入到全局中int main(){ printf("%d\n", N::a); printf("%d\n", b); Add(10, 20); return 0; }
当然,在命名空间N1中的rand被展开之后相当于加入到全局域,再使用的时候还是会和库里的rand()函数冲突
命名空间的存在奠基了C++能作为创建一个大工程的语言,一个大工程往往会分组安排任务,C++使得每个小组可以使用不同的命名空间,即使命名空间定义相同的名字,编译器也会帮你进行合并,这对于开发者来说十分方便!
以下是一些使用 C++ 开发的知名游戏:
《英雄联盟》:这是一款非常受欢迎的多人在线战斗竞技游戏。《使命召唤》系列:著名的第一人称射击游戏。《古墓丽影》系列:动作冒险游戏。《星际争霸》系列:经典的即时战略游戏。以及 绝地求生****巫师三等
3.2 C++的输入输出
讲到现在,我们甚至还不会用C++写一个“hello world”,这怎么行?上代码:
#include<iostream>using namespace std;int main(){ cout<<"Hello world!!!"<<endl; return 0;}
代码中,cout就是标准输出(控制台),除此之外还有cin——标准输入(即键盘),使用cout标准输出和cin标准输入时,必须包含< iostream >头文件以及std标准命名空间。(endl就是换行)
需要注意的是:早期标准库将所有功能在全局域中实现,声明在.h后缀的头文件中,使用时只需包含对应头文件即可,后来将其实现在std命名空间下,为了和C头文件区分,也为了正确使用命名空间,规定C++头文件不带.h;旧编译器(vc 6.0)中还支持<iostream.h>格式,后续编译器已不支持,因此推荐使用+std的方式。
估计细心的同学已经发现了,使用C++进行输入输出的时候不需要增加格式控制,也就是C语言格式化输入输出的%d、%c等。直接cin、cout即可。而且可以一个cin(cout)进行连续输入(输出)
#include <iostream>using namespace std;int main(){ int a; double b; char c; cin>>a; cin>>b>>c; cout<<a<<endl; cout<<b<<" "<<c<<endl; return 0;}
3.3 缺省参数
缺省,可能单看这个名字看不出来什么意思,但是找到它的英文就知道了:default,其实就是默认。
缺省参数是声明或定义函数时为函数的参数指定一个默认值。在调用该函数时,如果没有指定实参则采用该默认值,否则使用指定的实参。
缺省参数分为全缺省和半缺省
全缺省是这样的:
void Func(int a = 10, int b = 20, int c = 30) { cout<<"a = "<<a<<endl; cout<<"b = "<<b<<endl; cout<<"c = "<<c<<endl; }
Func函数中a,b,c都给了默认值,就叫全缺省
而半缺省长这样:
void Func(int a, int b = 20, int c = 30) { cout<<"a = "<<a<<endl; cout<<"b = "<<b<<endl; cout<<"c = "<<c<<endl; }
这里只有b和c给了缺省值,而a没有,就叫半缺省
需要注意的是,半缺省参数只能从右往左依次给出,不能间隔着给,而且缺省参数不能在函数声明和定义中同时出现
//in a.hvoid TestFunc(int a = 10);// in a.cvoid TestFunc(int a = 20){}// 注意:如果生命与定义位置同时出现,恰巧两个位置提供的值不同,那编译器就无法确定到底该用那个缺省值。
而且缺省值必须是常量或者全局变量。(C语言不支持这个语法)
3.4 函数重载
从前有一个笑话,国有两个体育项目大家根本不用看,也不用担心。一个是乒乓球,一个是男足。前
者是“谁也赢不了!”,后者是“谁也赢不了!”。这个笑话表明,自然语言中同样的句子可能有不同的含义,而函数重载就和这个类似。
函数重载:是函数的一种特殊情况,C++允许在同一作用域中声明几个功能类似的同名函数,这些同名函数的形参列表(参数个数 或 类型 或 顺序)必须不同,常用来处理实现功能类似数据类型不同的问题
int Add(int left, int right){ return left+right;}double Add(double left, double right){ return left+right;}long Add(long left, long right){ return left+right;}int main(){ Add(10, 20); Add(10.0, 20.0); Add(10L, 20L); return 0;}
代码中,有三个函数,名字都为Add,但是它们的参数类型不同
函数重载还可以是参数数量不同:
void f(){ cout << "f()" << endl;}void f(int a){ cout << "f(int a)" << endl;}
以及参数顺序不同:
void f(int a, char b){ cout << "比较航空航天大学" << endl;}void f(char b, int a){ cout << "哈尔滨佛学院" << endl;}
但是形参名字不行:
short Add(short x,short y){return x+y;}short Add(short y,short x){return x+y;}
此外要注意:返回值不同不能构成重载,如:
short Add(short left, short right){ return left+right;}int Add(short left, short right){ return left+right;}
那为什么C语言不支持而C++支持呢?
这就涉及它们整个编译的过程了:在C/C++中,一个程序要运行起来,需要经历以下几个阶段:预处理、编译、汇编、链接。在这些过程中,C++文件和C文件的处理不同。
C语言为什么不支持?
首先创建三个文件:func.h func.c main.c 在.h文件中声明两个函数:
int func(int x,int y);int func(int x,double y);
这三个文件会经历:
1.预处理:头文件展开、宏替换、条件编译以及去掉注释,这个过程结束之后func.h被展开了,main.c func.c成为func.i main.i文件
2.编译:语法检查和生成汇编代码func.i和main.i变成了func.s main.s(文件内容是汇编代码)
3.汇编:将汇编代码转换成二进制码,以便机器能够读懂,此时变成func.o main.o
4.链接(最关键):链接时,.o文件会合并在一起,而且还需找一些只给了声明的函数
的函数地址,而每一个.o文件都有一个符号表,符号表中存放函数的地址,当main文件要调用这个函数时,会去符号表中找函数的地址
而符号表中两个func函数的地址,编译器不知道应该调用哪个,所以c程序不支持函数重载。
那C++为什么支持呢?
相比起C程序而言,C++新增了一个函数名修饰规则来支持函数重载,这个规则就是将函数的参数带入符号表,所以参数的类型,数量,顺序不同,代表的是不同的函数,找地址时就不会出错!
将C++代码转到反汇编,我们可以看到:
函数参数的类型,数量,顺序不同,那么对应在符号表中的名字就不一样,main文件再去找函数地址时就不会冲突。这个命名规则C++标准并没有具体规定,由每个编译器自己决定,如VS2022就与g++不同(VS2022的命名规则比较诡异),但是一定可以保证的是,参数不同的函数名字不同
对比C语言,c程序符号表中只有一个函数名,函数参数没有参与进来,所以C程序不支持相同函数名的函数。
那么这时候就会有同学问了,如果在符号表命名规则中加入返回值不就可以让不同的返回值支持函数重载了吗?
答案是不能!
因为在调用的时候并没有返回值,比如下面这两个函数:
void add(double x, double y){return ;}double add(double x, double y){return x+y;}double a=0.5,b=0.5;
在调用的时候,我们只有add(a,b)
,那么即使符号表的问题解决了,调用规则摆在这,还是无法确定应该调用符号表中的哪个函数。(除非你把调用规则也改掉,让它在调用的时候带上返回值一起)
有时候在C++工程中可能需要将某些函数按照C的风格来编译,在函数前加extern “C”,意思是告诉编译器,将该函数按照C语言规则来编译。比如:tcmalloc是google用C++实现的一个项目,他提供tcmallc()和tcfree两个接口来使用,但如果是C项目就没办法使用,那么他就使用extern “C”来解决。
extern "C" int Add(int left, int right);int main(){ Add(1,2); return 0;}
此时就会链接时报错:error LNK2019: 无法解析的外部符号_Add,该符号在函数 _main 中被引用
因为函数按照C语言的规则来命名。
下面两个函数构成函数重载吗?
void TestFunc(int a = 10){ cout<<"void TestFunc(int)"<<endl;}void TestFunc(int a){ cout<<"void TestFunc(int)"<<endl;}
答案是不构成,因为对于TestFunc(int)编译器根本不知道应该调用哪个函数,会直接报错:
3.5 引用
众所周知,指针是C语言的精髓所在,引用之于C++犹如指针之于C,甚至C++不仅支持指针(java、python等语言都不支持指针),而且支持引用,可谓卧龙凤雏齐聚也。
什么是引用?
引用不是新定义一个变量,而是给已存在变量取了一个别名。
比如孙悟空,又叫孙行者,又叫齐天大圣,又叫弼马温,又叫斗战圣佛。
再比如我,除了我的名字,又叫全世界最帅的人(bushi)。
但是编译器不会为引用变量开辟内存空间,它和它引用的变量共用同一块内存空间。
int a=10;int& b=a;printf("%p\n%p",&a,&b);
引用是怎么使用的?
定义一个引用:
类型& 引用变量名(对象名) = 引用实体;
void TestRef(){ int a = 10; int& ra = a;//<====定义引用类型 printf("%p\n", &a); printf("%p\n", &ra);}
引用类型需要与引用实体同类
引用有三个特点:
1.必须在定义时初始化
2.一个变量可以有多个引用
3.一个引用一旦有了实体就不能再引用其他实体
void TestRef(){ int a = 10; // int& ra; // 该条语句编译时会出错 int& ra = a; int& rra = a; printf("%p %p %p\n", &a, &ra, &rra); }
对于常量的引用:
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;}
为什么不能直接引用一个常量呢?
这是因为引用的一个用处是,引用改变的时候实体也会跟着改变,因为它和引用实体占用同一个地址空间(就比如你让孙行者带上金箍,那孙悟空是不是也带上了金箍?)
int a = 10;int& b = a;cout << b << " " << a << endl;b = 20;cout << b << " " << a << endl;
可见a的值也被修改成了20。
但是常量之所以是常量,就是因为它不能被随意修改,如果用
const int a=10;int& ra=a;
那么a就存在被修改的可能性,对于a来讲,权限就被放大了,但是这样的权限放大是很危险的,所以有一个原则:可以进行权限缩小或者平移,但是不能扩大。所以我们可以像下面这样:
//平移int a=0;int& ra=a;//平移const int b=10;const int& rb=b;//缩小const int& ra1=a;
除了作为一个引用变量/常量,引用还有哪些使用场景呢?
作函数参数之前在写C程序的交换函数时,因为形参是实参的一份拷贝,想要改变实参就要传地址,而现在有了引用就不用传地址了!
void Swap(int& left, int& right)//交换函数{ int temp = left; left = right; right = temp;}int a = 10;int b = 20;Swap(a,b);//因为形参为引用,所以这传的是a,b的引用,所以形参的改变就是实参的改变
但是注意,不要这样传参:Swap(&a,&b)
,因为这样传进去的是地址(兼容C语言),广泛地说,&跟在类型后面是引用,跟在变/常量的前面是取地址。
引用作为返回值时,可以在函数外面修改函数里面的内容,前提是引用的变量出了函数也不会销毁
static int n = 0;int& Count1(){n++;n++;return n;}int& Count2(){int m=0;m++;return m;}int& tmp = Count1();int& tem = Count2();tmp = 20;tem = 10;cout << tmp << ' ' << n<<' '<<tem<<' '<<m;
这你Count2()函数所在的空间为栈空间,由函数栈帧的有关知识可知,进入函数时创建栈帧,m变量也在栈帧中,在出了函数之后其栈帧会被销毁,m的地址也会被释放,所以这是tmp的空间其实被释放过(我愿称之为野引用)而n由于存在静态区就不会被释放。
甚至给tem赋值也不会有变化:
所以如果实体在出函数后会被销毁的时候需要传值返回。
看到这,你有没有感觉这根指针很像?他们直接肯定有联系!
引用和指针的联系:
引用在语法概念上就是一个别名,和实体共用一份空间
但是引用在底层实现上是有空间的,因为引用是按照指针的方式来实现的,也就是说,指针的底层汇编和引用一样。
简直一模一样
但是它俩也有区别:
总的来说,C++中更喜欢使用引用,特别在一些容器中比如栈、队列等,在类和对象中也十分常见。
3.6 空指针nullptr
在C++98中,NULL表示空指针,一般在我们初始化一个指针的时候会用NULL,但实际上,NULL是一个宏,在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。
但是在C++11中,nullptr作为一个关键字被引入,所以使用它时不需要包含头文件,而且它就是(void*)0,这里建议以后进行C++的编程时表示空指针都用nullptr
3.7 内联函数
什么叫内联函数?
以inline修饰的函数叫做内联函数,编译时C++编译器会在调用内联函数的地方展开,没有函数压栈的开销,内联函数提升程序运行的效率。
在学习函数栈帧的时候我们了解到,在汇编代码中,函数的调用一定会用到call指令:
int Add(int left, int right){return left+right;}
就是return 0 上面两行那个call
inline int Add(int left, int right){return left+right;}
此时就没有call了,直接把Add函数内部的搬过来了
所以内联函数的本质就是,用函数体替换函数调用。
内联函数可以减少调用,提高运行效率,但因为可能把函数体多次展开,也可能使目标文件变大。
如果要是把一个很大的函数体多次展开,那岂不是十分冗杂?
编译器也想到了坐在电脑前的程序员可能是个笨蛋(比如我),所以它可以选择不展开。
inline对于编译器而言只是一个建议,若一个函数代码很长,则编译器不会将它变成内联
一般来说,函数代码在10行及以内时,这时编译器会将它优化为内联函数,有些编译器是在30行以内。
此外需要注意的是:
内联函数的定义和声明不能分开。因为inline被展开后,就没有函数地址了,链接时会找不到。
内联函数可以和宏替换有点类似,在某些宏替换的地方定义一个内联函数也可以
3.8 auto关键字
在C++的中后期,你可能会见到这样的代码:
__list_iterator<InputTterator>::iterator it = tmp.begin();
其中__list_iterator是模版
InputTterator:类型,用来实例化类
iterator:类的成员
it :变量
好长!太麻烦了!不想写这么多!
怎么办?
可以这样写:
__list_iterator<InputTterator>::iterator it = tmp.begin();//化简后auto it = tmp.begin();
为什么可以这样写?
这是因为,auto是一个特殊的类型,可以像int char那样使用,但是这个类型是由编译器自己推导出来的,比如:
int a = 10;auto b = a;auto c = 'a';
这里编译器推导出来 b是int,而c 是char。
auto还有一些其他的使用规则:
1.对指针和引用的区别
auto对指针来说“可有可无”
int x = 10;auto a = &x;auto* b = &x;
观察这段代码,a是int* 类型,此时auto也是int*,b也是int类型,此时auto就是int,所以如果对象是指针,使用auto时加/不加都可以
但对引用来说则必须加&
int x = 10;auto& c = x;auto d = x;
c的类型是int的引用,此时的auto是int;d的类型是int,此时的auto是int。所以如果要用auto创建一个引用变量,请加上&符号
2.一行多次定义
当在同一行声明多个变量时,这些变量必须是相同的类型,否则编译器将会报错
因为编译器实际只对第一个类型进行推导,然后用推导出来的类型定义其他变量
auto a = 1, b = 2; //没问题auto c = 3, d = 4.0; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
事实上,当第一个变量被推导成int后,第二个变量默认也是int.,但是int类型不能存放double类型的值,所以编译失败,因此也可以推断出:
auto a = 1, b = 2; //没问题auto c = 3, d = 'a'; // 该行代码会编译失败,因为c和d的初始化表达式类型不同
是没有问题的
3.auto无法使用的地方
(1)函数参数
void TestAuto(auto a){//...}
这个不用多说,因为编译器无法确定它的实际类型
(2)声明数组
int a[]={1,2,3};auto b[]={2,3,4};
直觉上,似乎b数组里都是整型,这完全可以推导出来啊。但是事实上不能,原因
(其实auto只和下面讲的范围for在一起用的时候比较常见,其他时候不常见)
3.9 基于范围的for循环
看下面这个代码
int arr[] = {1,2,3,4,5,6};for(auto e : arr){cout<<e <<" ";}
咦?似乎跟我们平时用的for不太一样
是的,这就是C++11引入的范围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;}
范围for循环的要求:
for循环迭代的范围必须是确定的对于数组而言,就是数组中第一个元素和最后一个元素的范围;对于类而言,应该提供begin和end的
方法,begin和end就是for循环迭代的范围。(后面会讲)
以下代码就有问题,因为for的范围不确定:
void TestFor(int array[]){ for(auto& e : array) cout<< e <<endl;}
迭代的对象要实现++和==的操作。(如果不能++和判相等还怎么迭代?)