第一部分是C++基础,第二部分是C/C++内存问题,第三部分是C++面向对象,第四部分是C++标准模板库STL,第五部分是C++新特性,第六部分是C++操作系统内容,第七部分是C++计算机网络,第八部分提到了部分常用的设计模式,第九部分举了一些常见的编程题。
内容参考自牛客
文章目录
- 1、C++基础
- 1.1 简述C++语言的特点
- 1.2 说说C语言与C++语言的区别
- 1.3 说说C++中struct和class的区别
- 1.4 说说C中结构体和C++中结构体的区别
- 1.5 说说include头文件的顺序以及双引号“”和尖括号<>的区别
- 1.6 导入C函数的关键字是什么,C++编译的时候与C有什么区别
- 1.7 简述C++从代码到可执行二进制文件的过程
- 1.8 说说 static关键字的作用
- 1.9 说说数组和指针的区别
- 1.10 说说什么是函数指针,如何定义函数指针,有什么使用场景
- 1.11 说说静态变量什么时候初始化
- 1.12 nullptr可以调用成员函数吗,为什么?
- 1.13 什么是野指针,怎么产生的,如何避免?
- 1.14 说说静态局部变量,全局变量,局部变量的特点,以及使用场景
- 1.15 说说内联函数和宏函数的区别
- 1.16 说说运算符i++和++i的区别
- 1.17 说说new和malloc的区别,各自底层实现原理
- 1.18 说说const和define的区别
- 1.19 说说C++中函数指针和指针函数的区别
- 1.20 说说const int *a, int const *a, const int a, int *const a, const int *const a分别是什么,有什么特点。
- 1.21 说说使用指针需要注意什么?
- 1.22 说说内联函数和函数的区别,内联函数的作用
- 1.23 简述C++有几种传值方式,之间的区别是什么?
- 1.24 简述const(星号)和(星号)const的区别
- 2、C/C++内存问题
- 2.7 简述C++中内存对齐的使用场景
- 3、C++面向对象
- 3.1 简述一下什么是面向对象
- 3.2 简述一下面向对象的三大特征
- 3.3 简述一下C++的重写与重载,以及他们之间的区别
- 3.4 C++如何实现重写与重载的?在C语言中怎么实现函数重载?
- 3.5 构造函数分为哪几种?
- 3.6 只定义析构函数,会自动生成哪些构造函数
- 3.7 说说一个类,默认会生成哪些函数
- 3.8 说说 C++ 类对象的初始化顺序,有多重继承情况下的顺序
- 3.9 简述下向上转型和向下转型
- 3.10 简述下深拷贝和浅拷贝,如何实现深拷贝
- 3.11 简述一下C++中的多态
- 3.12 为什么要虚析构,为什么不能虚构造?
- 3.13 说说模板类是在什么时候实现的
- 3.14 说说类继承时,派生类对不同关键字修饰的基类方法的访问权限
- 3.15 简述一下移动构造函数,什么库用到了这个函数?
- 3.16 请你回答一下 C++ 类内可以定义引用数据成员吗?
- 3.17 简述一下什么是常函数,有什么作用
- 3.18 说说什么是虚继承,解决什么问题,如何实现?
- 3.19 简述一下虚函数和纯虚函数,以及实现原理
- 3.20 说说纯虚函数能实例化吗,为什么?派生类要实现吗,为什么?
- 3.21 说说C++中虚函数与纯虚函数的区别
- 3.22 说说 C++ 中什么是菱形继承问题,如何解决
- 3.23 请问构造函数中的能不能调用虚方法
- 3.24 请问拷贝构造函数的参数是什么传递方式,为什么
- 3.25 如何理解抽象类?
- 3.26 什么是多态?除了虚函数,还有什么方式能实现多态?
- 3.27 简述一下虚析构函数,什么作用
- 3.28 说说什么是虚基类,可否被实例化?
- 3.29 简述一下拷贝赋值和移动赋值?
- 3.30 仿函数了解吗?有什么作用
- 3.31 C++ 中哪些函数不能被声明为虚函数?
- 3.32 解释下 C++ 中类模板和模板类的区别
- 3.33 虚函数表里存放的内容是什么时候写进去的?
- 4、标准模板库STL
- 5、C++新特性
- 6、C++操作系统(Linux相关)
- 7、计算机网络
- 8、设计模式(摘选)
- 9、常见编程大题
1、C++基础
1.1 简述C++语言的特点
- C++在C语言基础上引入了面向对象的机制,同时也兼容C语言。
- C++有三大特性:封装、继承、多态
- C++语言编写出的程序结构清晰、易于扩充,程序可读性好
- C++生成的代码质量高,运行效率高,仅比汇编语言慢10%~20%
- C++更加安全,增加了const常量、引用、四类cast转换(static_cast、dynamic_cast、const_cast、reinterpret_cast)、智能指针、try—catch等等
- C++可复用性高,C++引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL(Standard Template Library)
- 同时,C++是不断在发展的语言。C++后续版本更是发展了不少新特性,如C++11中引入了nullptr、auto变量、Lambda匿名函数、右值引用、智能指针。
1.2 说说C语言与C++语言的区别
- C语言是C++的子集,C++可以很好兼容C语言。但是C++又有很多新特性,如引用、智能指针、auto变量等。
- C++是面对对象的编程语言;C语言是面对过程的编程语言
- C语言有一些不安全的语言特性,如指针使用的潜在危险、强制转换的不确定性、内存泄露等。而C++对此增加了不少新特性来改善安全性,如const常量、引用、cast转换、智能指针、try—catch等等
- C++可复用性高,C++引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL。C++的STL库相对于C语言的函数库更灵活、更通用
1.3 说说C++中struct和class的区别
- struct一般用于描述一个数据结构集合,而class是对一个对象数据的封装;
- struct的默认访问权限是public,而class默认权限是private;
struct A{
int iNum; //默认访问控制权限是public
};
class B{
int iNum; //默认访问控制权限是private
};
- 在继承关系上,struct是public继承,而class是private继承;
- class关键字可以用于定义模板参数,就像typename,而struct不能用于定义模板参数。
1.4 说说C中结构体和C++中结构体的区别
- C的结构体内不允许有函数存在,C++允许有内部成员函数,且允许该函数是虚函数
- C的结构体对内部成员变量的访问权限只能是public,而C++允许public,protected,private三种
- C语言的结构体是不可以继承的,C++的结构体是可以从其他的结构体或者类继承过来的
- C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字直接使用
小总结一波:①C与C++在声明时的区别在于:
C | C++ | |
---|---|---|
成员函数 | 不能有 | 可以 |
静态成员 | 不能有 | 可以 |
访问控制 | 默认public,不可修改 | public/private/protected |
继承关系 | 不可以继承 | 可从类或其他结构体继承 |
初始化 | 不能直接初始化数据成员 | 可以 |
②使用时的区别:
C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字直接使用,例如:
struct Student{
int iAge;
string strName;
};
typedef struct Student Student2; //C中给结构体取别名
stuct Student stu1; //C中正常使用
Student2 stu2; //C中通过取别名来使用
Student stu3; // C++中使用结构体可以省略关键字struct
1.5 说说include头文件的顺序以及双引号“”和尖括号<>的区别
- 区别
(1)尖括号的头文件是系统文件,双引号的文件是自定义文件;
(2)编译器预处理阶段查找头文件的路径不一样 - 查找路径
(1)使用尖括号<>的头文件的查找路径:编译器设置的头文件路径–>系统变量
(2)使用双引号“”的头文件的查找路径:当前头文件目录–>编译器设置的头文件路径–>系统变量
1.6 导入C函数的关键字是什么,C++编译的时候与C有什么区别
- 关键字:在C++中,导入C函数的关键字是extern,表达形式为extern “C”, extern "C"的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern "C"后,会指示编译器这部分代码按C语言的进行编译,而不是C++的。
- 编译区别:由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。
程序示例:
//extern示例
//在C++程序里边声明该函数,会指示编译器这部分代码按C语言的进行编译
extern "C" int strcmp(const char *s1, const char *s2);
//在C++程序里边声明该函数
extern "C"{
#include <string.h>//string.h里边包含了要调用的C函数的声明
}
//两种不同的语言,有着不同的编译规则,比如一个函数fun,可能C语言编译的时候为_fun,而C++则是__fun__
1.7 简述C++从代码到可执行二进制文件的过程
C++和C语言类似,一个C++程序从源码到执行文件,有四个过程,预处理、编译、汇编、链接。
- 预处理:这个过程主要的处理操作如下:
(1) 将所有的#define删除,并且展开所有的宏定义
(2) 处理所有的条件预编译指令,如#if、#ifdef
(3) 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。
(4) 过滤所有的注释
(5) 添加行号和文件名标识。 - 编译:这个过程主要的处理操作如下:
(1) 词法分析:将源代码的字符序列分割成一系列的记号。
(2) 语法分析:对记号进行语法分析,产生语法树。
(3) 语义分析:判断表达式是否有意义。
(4) 代码优化:
(5) 目标代码生成:生成汇编代码。
(6) 目标代码优化: - 汇编:这个过程主要是将汇编代码转变成机器可以执行的指令。
- 链接:将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。
链接分为静态链接和动态链接。
静态链接,是在链接的时候就已经把要调用的函数或者过程链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;生成的静态链接库,Windows下以.lib为后缀,Linux下以.a为后缀。
而动态链接,是在链接的时候没有把调用的函数代码链接进去,而是在执行的过程中,再去找要链接的函数,生成的可执行文件中没有函数代码,只包含函数的重定位信息,所以当你删除动态库时,可执行程序就不能运行。生成的动态链接库,Windows下以.dll为后缀,Linux下以.so为后缀。
1.8 说说 static关键字的作用
static关键字的作用:一方面她延长了函数和变量的作用时间;另一方面,它也限制了函数和变量的作用范围。
- 定义全局静态变量和局部静态变量:在变量前面加上static关键字。初始化的静态变量会在数据段(.data)分配内存,未初始化的静态变量会在BSS段(.bss)分配内存。直到程序结束,静态变量始终会维持前值。只不过全局静态变量和局部静态变量的作用域不一样;
- 定义静态函数:在函数返回类型前加上static关键字,函数即被定义为静态函数。静态函数只能在本源文件中使用;
- 在变量类型前加上static关键字,变量即被定义为静态变量。静态变量只能在本源文件中使用;
- 在C++中,static关键字可以用于定义类中的静态成员变量:使用静态数据成员,它既可以被当成全局变量那样去存储,但又被隐藏在类的内部。类中的static静态数据成员拥有一块单独的存储区,而不管创建了多少个该类的对象。所有这些对象的静态数据成员都共享这一块静态存储空间;
- 在C++中,static关键字可以用于定义类中的静态成员函数:与静态成员变量类似,类里面同样可以定义静态成员函数。只需要在函数前加上关键字static即可。如静态成员函数也是类的一部分,而不是对象的一部分。所有这些对象的静态数据成员都共享这一块静态存储空间。
关于第五点的补充说明:当调用一个对象的非静态成员函数时,系统会把该对象的起始地址赋给成员函数的this指针。而静态成员函数不属于任何一个对象,因此C++规定静态成员函数没有this指针(划重点,面试题常考)。既然它没有指向某一对象,也就无法对一个对象中的非静态成员进行访问。
1.9 说说数组和指针的区别
1、概念:
(1)数组:数组是用于储存多个相同类型数据的集合。数组名是首元素的地址
(2)指针:指针相当于一个变量,但是它和不同变量不一样,它存放的是其它变量在内存中的地址。指针名指向了内存的首地址
- 区别:
(1)赋值:同类型指针变量可以相互赋值;数组不行,只能一个一个元素的赋值或拷贝
(2)存储方式:
数组:数组在内存中是连续存放的,开辟一块连续的内存空间。数组是根据数组的下标进行访问的,数组的存储空间,不是在静态区就是在栈上。
指针:指针很灵活,它可以指向任意类型的数据。指针的类型说明了它所指向地址空间的内存。由于指针本身就是一个变量,再加上它所存放的也是变量,所以指针的存储空间不能确定。
(3)求sizeof:
数组所占存储空间的内存大小是sizeof(数组),计算数组中元素个数:sizeof(数组名)/sizeof(数据类型)
在32位平台下,无论指针的类型是什么,sizeof(指针名)都是4,在64位平台下,无论指针的类型是什么,sizeof(指针名)都是8。
(4)初始化:
// 数组
int a[5] = { 0 };
char b[] = "Hello"; // 按字符串初始化,大小为6
char c[] = { 'H','e','l','l','o','\0' }; // 按字符初始化
int* arr = new int[10]; // 动态创建一维数组
// 指针
// 指向对象的指针
int* p = new int(0);
delete p;
// 指向数组的指针
int* p1 = new int[10];
delete[] p1;
// 指向类的指针:
string* p2 = new string;
delete p2;
// 指向指针的指针(二级指针)
int** pp = &p;
**pp = 10;
(5)指针操作:
数组名的指针操作:
int (*p)[4]
定义了一个数组指针,该指针指向一个包含4个元素的数组。
int a[3][4];
int (*p)[4]; //该语句是定义一个数组指针,指向含4个元素的一维数组
p = a; //将该二维数组的首地址赋给p,也就是a[0]或&a[0][0]
p++; //该语句执行过后,也就是p=p+1;p跨过行a[0][]指向了行a[1][]
//所以数组指针也称指向一维数组的指针,亦称行指针。
//访问数组中第i行j列的一个元素,有几种操作方式:
//*(p[i]+j)、*(*(p+i)+j)、(*(p+i))[j]、p[i][j]。其中,优先级:()>[]>*。
//这几种操作方式都是合法的。
指针变量的数据操作:
char *str = "hello,douya!";
str[2] = 'a';
*(str+2) = 'b';
//这两种操作方式都是合法的。
补充:int *p[4]和int (p)[4]的区别和区分:因为[]的优先级要比*高,所以前者是p先和[ ]结合,表示一个数组,然后再与结合,表示int指针类型的数组。(本质上是数组,不过是int指针类型的数组)这数组里每个元素都是int类型的指针,储存的是int类型变量的地址。
而int(*p)[4],p先和*结合,表示p是一个指针,int [4]就是他的类型,表示这是一个数组类型的指针,即p是一个指向一个长度为4数组的指针,它的本质是一个指针,这个指针的类型是int类型长度为4的数组。表示,这个指针存的是int类型长度为4的数组的地址。(不是其中某个元素的地址)
1.10 说说什么是函数指针,如何定义函数指针,有什么使用场景
- 概念:函数指针就是指向函数的指针变量。每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址
- 定义形式如下:
int func(int a);
int (*f)(int a);
f = &func;
- 函数指针的应用场景:回调(callback)。我们调用别人提供的 API函数(Application Programming Interface,应用程序编程接口),称为Call;如果别人的库里面调用我们的函数,就叫回调Callback。
我们用系统的API叫调用,系统用我们的API叫回调。
程序示例:
//以库函数qsort排序函数为例,它的原型如下:
void qsort(void *base,//void*类型,代表原始数组
size_t nmemb, //第二个是size_t类型,代表数据数量
size_t size, //第三个是size_t类型,代表单个数据占用空间大小
int(*compar)(const void *,const void *)//第四个参数是函数指针
);
//第四个参数告诉qsort,应该使用哪个函数来比较元素,即只要我们告诉qsort比较大小的规则,它就可以帮我们对任意数据类型的数组进行排序。在库函数qsort调用我们自定义的比较函数,这就是回调的应用。
//示例
int num[100];
int cmp_int(const void* _a , const void* _b){//参数格式固定
int* a = (int*)_a; //强制类型转换
int* b = (int*)_b;
return *a - *b;
}
qsort(num,100,sizeof(num[0]),cmp_int); //回调
1.11 说说静态变量什么时候初始化
对于C语言的全局和静态变量,初始化发生在任何代码执行之前,属于编译期初始化。
而C++标准规定:全局或静态对象当且仅当对象首次用到时才进行构造。
- 作用域:C++里作用域可分为6种:全局,局部,类,语句,命名空间和文件作用域
全局变量:全局作用域+文件作用域,可以通过extern作用于其他非定义的源文件。
静态全局变量 :全局作用域,所以无法在其他文件中使用。
静态局部变量 :局部作用域,只被初始化一次,直到程序结束。
类静态成员变量:类作用域。
-
所在空间:都在静态存储区。因为静态变量都在静态存储区,所以下次调用函数的时候还是能取到原来的值
-
生命周期:静态全局变量、静态局部变量都在静态存储区,直到程序结束才会回收内存。类静态成员变量在静态存储区,当超出类作用域时回收内存
1.12 nullptr可以调用成员函数吗,为什么?
答案:能。
原因:因为在编译时对象就绑定了函数地址,和指针空不空没关系。
因为在编译时对象就绑定了函数地址,和指针空不空没关系。pAn->breathe();编译的时候,函数的地址就和指针pAn绑定了;调用breath(*this), this就等于pAn。由于函数中没有需要解引用this的地方,所以函数运行不会出错,但是若用到this,因为this=nullptr,运行出错。
//给出实例
class animal{
public:
void sleep(){ cout << "animal sleep" << endl; }
void breathe(){ cout << "animal breathe haha" << endl; }
};
class fish :public animal{
public:
void breathe(){ cout << "fish bubble" << endl; }
};
int main(){
animal *pAn=nullptr;
pAn->breathe(); // 输出:animal breathe haha
fish *pFish = nullptr;
pFish->breathe(); // 输出:fish bubble
return 0;
}
1.13 什么是野指针,怎么产生的,如何避免?
- 概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
- 产生原因:释放内存后指针不及时置空(野指针),依然指向了该内存,那么可能出现非法访问的错误。这些我们都要注意避免,如:
char *p = (char *)malloc(sizeof(char)*100);
strcpy(p, "Douya");
free(p);//p所指向的内存被释放,但是p所指的地址仍然不变
...
if (p != NULL){//没有起到防错作用
strcpy(p, "hello, Douya!");//出错
}
- 避免办法:
(1)初始化置为NULL
(2)申请内存后判空
(3)指针释放后置NULL
(4)使用智能指针
int *p = NULL; //初始化置NULL
p = (int *)malloc(sizeof(int)*n); //申请n个int内存空间
assert(p != NULL); //判空,防错设计
//C 库函数 void *realloc(void *ptr, size_t size) 尝试重新调整之前调用 malloc 或 calloc 所分配的 ptr 所指向的内存块的大小。
//注意:如果大小为 0,且 ptr 指向一个已存在的内存块,则 ptr 所指向的内存块会被释放,并返回一个空指针。
p = (int *) realloc(p, 25);//重新分配内存, p 所指向的内存块会被释放并分配一个新的内存地址
free(p);
p = NULL; //释放后置空
int *p1 = NULL; //初始化置NULL
calloc默认设置分配的内存数据为0
p1 = (int *)calloc(n, sizeof(int)); //申请n个int内存空间同时初始化为0
assert(p1 != NULL); //判空,防错设计
free(p1);
p1 = NULL; //释放后置空
int *p2 = NULL; //初始化置NULL
p2 = new int[n]; //申请n个int内存空间
assert(p2 != NULL); //判空,防错设计
delete []p2;
p2 = nullptr; //释放后置空
1.14 说说静态局部变量,全局变量,局部变量的特点,以及使用场景
- 首先从作用域考虑:C++里作用域可分为6种:全局,局部,类,语句,命名空间和文件作用域。
全局变量:全局作用域+文件作用域,可以通过extern作用于其他非定义的源文件。
静态全局变量 :全局作用域,所以无法在其他文件中使用。
局部变量:局部作用域,比如函数的参数,函数内的局部变量等等。
静态局部变量 :局部作用域,只被初始化一次,直到程序结束。 - 从所在空间考虑:除了局部变量在栈上外,其他都在静态存储区。因为静态变量都在静态存储区,所以下次调用函数的时候还是能取到原来的值。
- 生命周期: 局部变量在栈上,出了作用域就回收内存;而全局变量、静态全局变量、静态局部变量都在静态存储区,直到程序结束才会回收内存。
- 使用场景:从它们各自特点就可以看出各自的应用场景,不再赘述。
1.15 说说内联函数和宏函数的区别
区别:
-
宏定义不是函数,但是使用起来像函数。预处理器用复制宏代码的方式代替函数的调用,省去了函数压栈退栈过程,提高了效率;而内联函数本质上是一个函数,内联函数一般用于函数体的代码比较简单的函数,不能包含复杂的控制语句,while、switch,并且内联函数本身不能直接调用自身(内联函数不能用于迭代)。
-
宏定义是在预编译的时候把所有的宏名用宏体来替换,简单的说就是字符串替换 ;而内联函数则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率
-
宏定义是没有类型检查的,无论对还是错都是直接替换;而内联函数在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等
说人话:相同点是,宏定义和内联函数,相比于函数体内代码较短,逻辑简单的普通函数而言,避免了出栈入栈的开销,提高了运行效率。不同的在于,宏定义本质不是函数。只是简单的字符替换,而且不会进行检查;内联函数本质是函数,是进行代码插入,有检错(宏定义不会对传入的参数进行检查),有形参,有返回值。
代码示例如下:
//宏定义示例
#define MAX(a,b) ((a)>(b)?(a):(b))
MAX(a,"Hello"); //错误地比较int和字符串,没有参数类型检查
//内联函数示例
#include <stdio.h>
inline int add(int a, int b){
return (a + b);
}
int main(void){
int a;
a = add(1, 2);
printf("a+b=%d\n", a);
return 0;
}
//以上a = add(1, 2);处在编译时将被展开为:a = (a + b);
1、使用时的一些注意事项:
-
使用宏定义一定要注意错误情况的出现,比如宏定义函数没有类型检查,可能传进来任意类型,从而带来错误,如举例。还有就是括号的使用,宏在定义时要小心处理宏参数,一般用括号括起来,否则容易出现二义性
-
inline函数一般用于比较小的,频繁调用的函数,这样可以减少函数调用带来的开销。只需要在函数返回类型前加上关键字inline,即可将函数指定为inline函数。
-
同其它函数不同的是,最好将inline函数定义在头文件,而不仅仅是声明,因为编译器在处理inline函数时,需要在调用点内联展开该函数,所以仅需要函数声明是不够的。
2、内联函数使用的条件:
-
内联是以代码膨胀(复制)为代价,仅仅省去了函数调用的开销,从而提高函数的执行效率。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。另一方面,每一处内联函数的调用都要复制代码,将使程序的总代码量增大,消耗更多的内存空间。以下情况不宜使用内联:
-
(1)如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
-
(2)如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。
-
内联不是什么时候都能展开的,一个好的编译器将会根据函数的定义体,自动地取消不符合要求的内联。
1.16 说说运算符i++和++i的区别
先看代码示例:
#include <stdio.h>
int main(){
int i = 2;
int j = 2;
j += i++; //先赋值后加
printf("i= %d, j= %d\n",i, j); //i= 3, j= 4
i = 2;
j = 2;
j += ++i; //先加后赋值
printf("i= %d, j= %d",i, j); //i= 3, j= 5
}
区别:
- 赋值顺序不同:++ i 是先加后赋值;i ++ 是先赋值后加;++i和i++都是分两步完成的。
- 效率不同:后置++执行速度比前置的慢。
- i++ 不能作为左值,而++i 可以:
int i = 0;
int *p1 = &(++i);//正确
int *p2 = &(i++);//错误
++i = 1;//正确
i++ = 1;//错误
- 两者都不是原子操作
1.17 说说new和malloc的区别,各自底层实现原理
- new是操作符,而malloc是函数。
- new在调用的时候先分配内存,在调用构造函数,释放的时候调用析构函数;而malloc没有构造函数和析构函数。
- malloc需要给定申请内存的大小,返回的指针需要强转;new会调用构造函数,不用指定内存的大小,返回指针不用强转。
- new可以被重载;malloc不行
- new分配内存更直接和安全。
- new发生错误抛出异常,malloc返回null
答案解析:
malloc底层实现:当开辟的空间小于 128K 时,调用 brk()函数;当开辟的空间大于 128K 时,调用mmap()。malloc采用的是内存池的管理方式,以减少内存碎片。先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲快。采用隐式链表将所有空闲块,每一个空闲块记录了一个未分配的、连续的内存地址。
new底层实现:关键字new在调用构造函数的时候实际上进行了如下的几个步骤:
- 创建一个新的对象
- 将构造函数的作用域赋值给这个新的对象(因此this指向了这个新的对象)
- 执行构造函数中的代码(为这个新对象添加属性)
- 返回新对象
1.18 说说const和define的区别
const用于定义常量;而define用于定义宏,而宏也可以用于定义常量。都用于常量定义时,它们的
区别有:
- const生效于编译的阶段;define生效于预处理阶段。
- const定义的常量,在C语言中是存储在内存中、需要额外的内存空间的;define定义的常量,运行时是直接的操作数,并不会存放在内存中。
- const定义的常量是带类型的;define定义的常量不带类型。因此define定义的常量不利于类型检查。
1.19 说说C++中函数指针和指针函数的区别
-
定义不同
指针函数本质是一个函数,其返回值为指针。
函数指针本质是一个指针,其指向一个函数。 -
写法不同
指针函数:int *fun(int x,int y);
函数指针:int (*fun)(int x,int y);
- 用法不同
用法参考以下代码示例:
//指针函数示例
typedef struct _Data{
int a;
int b;
}Data;
//指针函数
Data* f(int a,int b){
Data * data = new Data;
//...
return data;
}
int main(){
//调用指针函数
Data * myData = f(4,5);
//Data * myData = static_cast<Data*>(f(4,5));
//...
}
//函数指针示例
int add(int x,int y){
return x+y;
}
//函数指针
int (*fun)(int x,int y);
//赋值
fun = add;
//调用
cout << "(*fun)(1,2) = " << (*fun)(1,2) ;
//输出结果
//(*fun)(1,2) = 3
1.20 说说const int *a, int const *a, const int a, int *const a, const int *const a分别是什么,有什么特点。
参考答案:
1. const int a; //指的是a是一个常量,不允许修改。
2. const int *a; //a指针所指向的内存里的值不变,即(*a)不变
3. int const *a; //同const int *a;
4. int *const a; //a指针所指向的内存地址不变,即a不变
5. const int *const a; //都不变,即(*a)不变,a也不变
1.21 说说使用指针需要注意什么?
- 定义指针时,先初始化为NULL。
- 用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。
- 不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
- 避免数字或指针的下标越界,特别要当心发生“多1”或者“少1”操作
- 动态内存的申请与释放必须配对,防止内存泄漏
- 用free或delete释放了内存之后,立即将指针设置为NULL,防止“野指针”
示例代码:
//(1)初始化置NULL
//(2)申请内存后判空
//(3)指针释放后置NULL
int *p = NULL; //初始化置NULL
p = (int *)malloc(sizeof(int)*n); //申请n个int内存空间
assert(p != NULL); //判空,防错设计
p = (int *) realloc(p, 25);//重新分配内存, p 所指向的内存块会被释放并分配一个新的内存地址
free(p);
p = NULL; //释放后置空
int *p1 = NULL; //初始化置NULL
p1 = (int *)calloc(n, sizeof(int)); //申请n个int内存空间同时初始化为0
assert(p1 != NULL); //判空,防错设计
free(p1);
p1 = NULL; //释放后置空
int *p2 = NULL; //初始化置NULL
p2 = new int[n]; //申请n个int内存空间
assert(p2 != NULL); //判空,防错设计
delete []p2;
p2 = nullptr; //释放后置空
1.22 说说内联函数和函数的区别,内联函数的作用
- 内联函数比普通函数多了关键字inline
- 内联函数避免了函数调用的开销;普通函数有调用的开销
- 普通函数在被调用的时候,需要寻址(函数入口地址);内联函数不需要寻址。
- 内联函数有一定的限制,内联函数体要求代码简单,不能包含复杂的结构控制语句;普通函数没有这个要求。
内联函数的作用:内联函数在调用时,是将调用表达式用内联函数体来替换。避免函数调用的开销。
在使用内联函数时,应注意如下几点:
- 在内联函数内不允许用循环语句和开关语句。
如果内联函数有这些语句,则编译将该函数视同普通函数那样产生函数调用代码,递归函数是不能被用来做内联函数的。内联函数只适合于只有1~5行的小函数。对一个含有许多语句的大函数,函数调用和返回的开销相对来说微不足道,所以也没有必要用内联函数实现。
2. 内联函数的定义必须出现在内联函数第一次被调用之前。
1.23 简述C++有几种传值方式,之间的区别是什么?
传参方式有这三种:值传递、引用传递、指针传递
- 值传递:形参即使在函数体内值发生变化,也不会影响实参的值;
- 引用传递:形参在函数体内值发生变化,会影响实参的值;
- 指针传递:在指针指向没有发生改变的前提下,形参在函数体内值发生变化,会影响实参的值;
值传递用于对象时,整个对象会拷贝一个副本,这样效率低;而引用传递用于对象时,不发生拷贝行为,只是绑定对象,更高效;指针传递同理,但不如引用传递安全。
//代码示例
#include <iostream>
using namespace std;
void testfunc(int a, int *b, int &c){//形参a值发生了改变,但是没有影响实参i的值;但形参*b、c的值发生了改变,影响到了实参*j、k的值
a += 1;
(*b) += 1;
c += 1;
printf("a= %d, b= %d, c= %d\n",a,*b,c);//a= 2, b= 2, c= 2
}
int main(){
int i = 1;
int a = 1;
int *j = &a;
int k = 1;
testfunc(i, j, k);
printf("i= %d, j= %d, k= %d\n",i,*j,k);//i= 1, j= 2, k= 2
return 0;
}
1.24 简述const(星号)和(星号)const的区别
代码示例:
//const* 是常量指针,*const 是指针常量
int const *a; //a指针所指向的内存里的值不变,即(*a)不变
int *const a; //a指针所指向的内存地址不变,即a不变
2、C/C++内存问题
2.1 简述一下堆和栈的区别
- 堆栈空间分配不同。栈由操作系统自动分配释放 ,存放函数的参数值,局部变量的值等;堆一般由程序员分配释放。
- 堆栈缓存方式不同。栈使用的是一级缓存, 它们通常都是被调用时处于存储空间中,调用完毕立即释放;堆则是存放在二级缓存中,速度要慢些。
- 堆栈数据结构不同。堆类似数组结构;栈类似栈结构,先进后出。
2.2 简述C++的内存管理
- 内存分配方式
在C++中,内存分成5个区,他们分别是堆、栈、自由存储区、全局/静态存储区和常量存储区。
栈,在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。
堆,就是那些由malloc/realloc/calloc分配的内存块,一般一个malloc就要对应一个free。
自由存储区,就是那些由new分配的内存块,和堆是十分相似的,不过是用delete来结束自己的生命。 【补充说明】
全局/静态存储区,全局变量和静态变量被分配到同一块内存中
常量存储区,这是一块比较特殊的存储区,里面存放的是常量,不允许修改。
【补充说明】:
如果我接着问你自由存储区与堆有什么区别,你或许这样回答:“malloc在堆上分配的内存块,使用free释放内存,而new所申请的内存则是在自由存储区上,使用delete来释放。”
这样听起来似乎也没错,但如果我接着问:
自由存储区与堆是两块不同的内存区域吗?它们有可能相同吗?
你可能就懵了。
尽管C++标准没有要求,但很多编译器的new/delete都是以malloc/free为基础来实现的。那么请问:借以malloc实现的new,所申请的内存是在堆上还是在自由存储区上? 明显,此时的堆与自由存储区所代表的空间是相同的。
如果,你重载了new运算符,使其不在堆上开辟空间,那么对于自动存储区的概念就完全不相同了。
从技术上来说,堆(heap)是C语言和操作系统的术语。堆是操作系统所维护的一块特殊内存,它提供了动态分配的功能,当运行程序调用malloc()时就会从中分配,稍后调用free可把内存交还。而自由存储是C++中通过new和delete动态分配和释放对象的抽象概念,通过new来申请的内存区域可称为自由存储区。基本上,所有的C++编译器默认使用堆来实现自由存储,也即是缺省的全局运算符new和delete也许会按照malloc和free的方式来被实现,这时藉由new运算符分配的对象,说它在堆上也对,说它在自由存储区上也正确。但程序员也可以通过重载操作符,改用其他内存来实现自由存储,例如全局变量做的对象池,这时自由存储区就区别于堆了。我们所需要记住的就是:
堆是操作系统维护的一块内存,而自由存储是C++中通过new与delete动态分配和释放对象的抽象概念。堆与自由存储区并不等价。
【补充说明结束】
2. 常见的内存错误和对策
(1)内存分配未成功,却使用了它。
(2)内存分配虽然成功,但是尚未初始化就引用它。
(3)内存分配成功并且已经初始化,但操作越过了内存的边界。
(4)忘记了释放内存,造成内存泄露。
(5)释放了内存却继续使用它。
对策:
(1)定义指针时,先初始化为NULL。
(2)用malloc或new申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。
(3)不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用。
(4)避免数字或指针的下标越界,特别要当心发生“多1”或者“少1”操作
(5)动态内存的申请与释放必须配对,防止内存泄漏
(6)用free或delete释放了内存之后,立即将指针设置为NULL,防止“野指针”
(7)使用智能指针。
- 内存泄漏及解决办法
什么是内存泄露?
简单地说就是申请了一块内存空间,使用完毕后没有释放掉。(1)new和malloc申请资源使用后,没有用delete和free释放;(2)子类继承父类时,父类析构函数不是虚函数。(3)Windows句柄资源使用后没有释放。
怎么检测?
第一:良好的编码习惯,使用了内存分配的函数,一旦使用完毕,要记得使用其相应的函数释放掉。
第二:将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。
第三:使用智能指针。
第四:一些常见的工具插件,如ccmalloc、Dmalloc、Leaky、Valgrind等等。
2.3 malloc和局部变量分配在堆还是栈?
malloc是在堆上分配内存,需要程序员自己回收内存;局部变量是在栈中分配内存,超过作用域就自动回收。
2.4 程序有哪些section,分别的作用?程序启动的过程?怎么判断数据分配在栈上还是堆上?
一个程序有哪些section:
如上图,从低地址到高地址,一个程序由代码段、数据段、 BSS 段组成。
【1】、数据段:存放程序中已初始化的全局变量和静态变量的一块内存区域。
【2】、代码段:存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量。(全局常量和字符串常量位于全局区/静态区还是代码区,还有待商榷)
【3】、BSS 段:存放程序中未初始化的全局变量和静态变量的一块内存区域。
【4】、可执行程序在运行时又会多出两个区域:堆区和栈区。
堆区:动态申请内存用。堆从低地址向高地址增长。
栈区:存储局部变量、函数参数值。栈从高地址向低地址增长。是一块连续的空间。
【5】、最后还有一个文件映射区,位于堆和栈之间。
【警告】:在网上查阅的资料显示,字符串常量存放在全局/静态区,而不是代码区。普通常量也是位于全局/静态区。根据观察和代码验证,字符串数组是变量,存放在栈区;字符串指针是常量,存放在全局/静态区。还有一点,全局/静态区是低地址,栈是高地址。
#include <stdio.h>
void test01()
{
char str1[] = "abcd";
char str2[] = "abcd";
const char str3[] = "abcd";
const char str4[] = "abcd";
const char *str5 = "abcd";
const char *str6 = "abcd";
char *str7 = "abcd";
char *str8 = "abcd";
printf("str1: %p\n",str1);
printf("str2: %p\n",str2);
printf("str3: %p\n",str3);
printf("str4: %p\n",str4);
printf("str5: %p\n",str5);
printf("str6: %p\n",str6);
printf("str7: %p\n",str7);
printf("str8: %p\n",str8);
}
void test02()
{
char p[]="hello world!";
printf("test02 p[] is %p\n",p);
char *p1="hello world!";
printf("test02 *p is %p\n",p1);
}
int main()
{
test01();
test02();
return 0;
}
//输出结果为:
str1: 0061FEEB //栈 char str1[] = "abcd";
str2: 0061FEE6 //栈 char str2[] = "abcd";
str3: 0061FEE1 //栈 const char str3[] = "abcd";
str4: 0061FEDC //栈 const char str4[] = "abcd";
str5: 00405094 //全局/静态区 const char str3[] = "abcd";
str6: 00405094 //全局/静态区 const char *str6 = "abcd";
str7: 00405094 //全局/静态区 char *str7 = "abcd";
str8: 00405094 //全局/静态区 char *str8 = "abcd";
test02 p[] is 0061FEEF //栈 char p[]="hello world!";
test02 *p is 00405064 //全局/静态区 char *p1="hello world!";
注意!字符串指针所表示的是字符串常量,并且全局常量和字符串常量都是位于全局/静态变量区,但是,局部常量位于栈区。演示代码如下所示:
#include <stdio.h>
//已初始化的全局变量 --全局区的.data区
int g_a=10;
int g_b=12;
//已初始化的全局静态变量 --全局区的.data区
static int s_g_a=10;
static int s_g_b=10;
//全局常量 全局区
const int g_c_a=10;
const int g_c_b=11;
void test01()
{
//静态局部变量 全局区
static int s_a=10;
static int s_b=11;
//局部常量 位于栈区
const int l_g_c_a=10;
const int l_g_c_b=11;
printf("全局变量g_a的地址是:%p\n",&g_a);
printf("全局变量g_b的地址是:%p\n",&g_b);
printf("全局静态变量s_g_a的地址是:%p\n",&s_g_a);
printf("全局静态变量s_g_b的地址是:%p\n",&s_g_b);
printf("静态局部变量s_a的地址是:%p\n",&s_a);
printf("静态局部变量s_b的地址是:%p\n",&s_b);
printf("全局常量g_a的地址是:%p\n",&g_c_a);
printf("全局常量g_b的地址是:%p\n",&g_c_b);
printf("局部常量g_a的地址是:%p\n",&l_g_c_a);
printf("局部常量g_b的地址是:%p\n",&l_g_c_b);
//字符串常量 全局区
printf("字符串常量的地址是:%p\n",&"Hello1");
printf("字符串常量的地址是:%p\n",&"Hello2");
}
int main()
{
test01();
return 0;
}
//测试结果为:
全局变量g_a的地址是:00404004
全局变量g_b的地址是:00404008
全局静态变量s_g_a的地址是:0040400C
全局静态变量s_g_b的地址是:00404010
静态局部变量s_a的地址是:00404014
静态局部变量s_b的地址是:00404018
全局常量g_a的地址是:00405064
全局常量g_b的地址是:00405068
局部常量g_a的地址是:0061FF0C
局部常量g_b的地址是:0061FF08
字符串常量的地址是:0040517D
字符串常量的地址是:0040519C
有一种说法:程序只分text区、data区、bss区,当程序运行时才会增加堆区和栈区。(linux下查看代码二进制的elf格式)
程序启动的过程:
1、操作系统首先创建相应的进程并分配私有的进程空间,然后操作系统的加载器负责把可执行文件的数据段和代码段映射到进程的虚拟内存空间中。
2、加载器读入可执行程序的导入符号表,根据这些符号表可以查找出该可执行程序的所有依赖的动态链接库。
3、加载器针对该程序的每一个动态链接库调用LoadLibrary
(1)查找对应的动态库文件,加载器为该动态链接库确定一个合适的基地址。
(2)加载器读取该动态链接库的导入符号表和导出符号表,比较应用程序要求的导入符号是否匹配该库的导出符号。
(3)针对该库的导入符号表,查找对应的依赖的动态链接库,如有跳转,则跳到3
(4)调用该动态链接库的初始化函数
4、初始化应用程序的全局变量,对于全局对象自动调用构造函数。
5、进入应用程序入口点函数开始执行。
怎么判断数据分配在栈上还是堆上:首先局部变量分配在栈上;而通过malloc和new申请的空间是在堆上。
2.5 初始化为0的全局变量在bss还是data
答:全局变量和静态变量,初始化了的存入data段,未初始化的存入bss段。
2.6 请简述一下atomoic内存顺序
有六个内存顺序选项可应用于对原子类型的操作:
memory_order_relaxed:在原子类型上的操作以自由序列执行,没有任何同步关系,仅对此操作要求原子性。
memory_order_consume:memory_order_consume只会对其标识的对象保证该对象存储先行于那些需要加载该对象的操作。
memory_order_acquire:使用memory_order_acquire的原子操作,当前线程的读写操作都不能重排到此操作之前。
memory_order_release:使用memory_order_release的原子操作,当前线程的读写操作都不能重排到此操作之后。
memory_order_acq_rel:memory_order_acq_rel在此内存顺序的读-改-写操作既是获得加载又是释放操作。没有操作能够从此操作之后被重排到此操作之前,也没有操作能够从此操作之前被重排到此操作之后。
memory_order_seq_cst:memory_order_seq_cst比std::memory_order_acq_rel更为严格。memory_order_seq_cst不仅是一个"获取释放"内存顺序,它还会对所有拥有此标签的内存操作建立一个单独全序。
除非你为特定的操作指定一个顺序选项,否则内存顺序选项对于所有原子类型默认都是memory_order_seq_cst。
2.7 简述C++中内存对齐的使用场景
内存对齐应用于三种数据类型中:struct/class/union
struct/class/union内存对齐原则有四个:
- 数据成员对齐规则:结构(struct)或联合(union)的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小的整数倍开始。
- 结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部"最宽基本类型成员"的整数倍地址开始存储。(struct a里存有struct b,b里有char,int ,double等元素,那b应该从8的整数倍开始存储)。
- 收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的"最宽基本类型成员"的整数倍。不足的要补齐。(基本类型不包括struct/class/uinon)。
- sizeof(union),以结构里面size最大元素为union的size,因为在某一时刻,union只有一个成员真正存储于该地址。
补充说明:
- 什么是字节对齐
那么什么是字节对齐?在C语言中,结构体是一种复合数据类型,其构成元素既可以是基本数据类型(如int、long、float等)的变量,也可以是一些复合数据类型(如数组、结构体、联合体等)的数据单元。在结构体中,编译器为结构体的每个成员按其自然边界(alignment)分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构体的地址相同。
为了使CPU能够对变量进行快速的访问,变量的起始地址应该具有某些特性,即所谓的“对齐”,比如4字节的int型,其起始地址应该位于4字节的边界上,即起始地址能够被4整除,也即“对齐”跟数据在内存中的位置有关。如果一个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。
比如在32位cpu下,假设一个整型变量的地址为0x00000004(为4的倍数),那它就是自然对齐的,而如果其地址为0x00000002(非4的倍数)则是非对齐的。现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定类型变量的时候经常在特定的内存地址访问,这就需要各种类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。
- 为什么要字节对齐
需要字节对齐的根本原因在于CPU访问数据的效率问题。假设上面整型变量的地址不是自然对齐,比如为0x00000002,则CPU如果取它的值的话需要访问两次内存,第一次取从0x00000002-0x00000003的一个short,第二次取从0x00000004-0x00000005的一个short然后组合得到所要的数据,如果变量在0x00000003地址上的话则要访问三次内存,第一次为char,第二次为short,第三次为char,然后组合得到整型数据。
而如果变量在自然对齐位置上,则只要一次就可以取出数据。一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。
各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出这32bit,而如果存放在奇地址开始的地方,就需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该32bit数据。显然在读取效率上下降很多。
- 字节对齐实例:
union example {
int a[5];
char b;
double c;
};
int result = sizeof(example);
/*
联合体只取最长的,int a[5],长度为20,字节对齐为24
*/
struct example {
int a[5];
char b;
double c;
}test_struct;
int result = sizeof(test_struct);
/*
struct总和 int 20 分为 8 8 4 char 1 double 8 最大对齐8 char补充到int的4 所以为 8+8+8(4+1)+8(double),总计32
*/
struct example {
char b;
double c;
int a;
}test_struct;
int result = sizeof(test_struct);
/*
字节对齐除了内存起始地址要是数据类型的整数倍以外,还要满足一个条件,那就是占用的内存空间大小需要是结构体中占用最大内存空间的类型的整数倍,所以20不是double(8Byte)的整数倍,我们还要扩充四个字节,最后的结果是result=24
*/
3、C++面向对象
3.1 简述一下什么是面向对象
-
面向对象是一种编程思想,把一切东西看成是一个个对象,比如人、耳机、鼠标、水杯等,他们各自都有属性,比如:耳机是白色的,鼠标是黑色的,水杯是圆柱形的等等,把这些对象拥有的属性变量和操作这些属性变量的函数打包成一个类来表示
-
面向过程和面向对象的区别
面向过程:根据业务逻辑从上到下写代码
面向对象:将数据与函数绑定到一起,进行封装,这样能够更快速的开发程序,减少了重复代码的重写过程
3.2 简述一下面向对象的三大特征
封装、继承、多态
-
封装:将数据和操作数据的方法进行有机结合,隐藏对象的属性和实现细节,仅对外公开接口来和对象进行 交互。封装本质上是一种管理:我们如何管理兵马俑呢?比如如果什么都不管,兵马俑就被随意破坏了。那么我们首先建了一座房子把兵马俑给封装起来。但是我们目的全封装起来,不让别人看。所以我们开放了售票通道,可以买票突破封装在合理的监管机制下进去参观。类也是一样,不想给别人看到的,我们使用protected/private把成员封装起来。开放一些公有的成员函数对成员合理的访问。所以封装本质是一种管理。
-
继承:可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。
三种继承关系:
继承方式 | private继承 | protected继承 | public继承 |
---|---|---|---|
基类的private成员 | 不可见 | 不可见 | 不可见 |
基类的protected成员 | 变为private成员 | 仍为protected成员 | 仍为protected成员 |
基类的public成员 | 变为private成员 | 变为protected成员 | 仍为protected成员 |
- 多态:用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。实现多态,有二种方式,重写,重载。(虚函数/纯虚函数、函数重载)
3.3 简述一下C++的重写与重载,以及他们之间的区别
1、重写
是指派生类中存在重新定义的函数。其函数名,参数列表,返回值类型,所有都必须同基类中被重写的函数一致。只有函数体不同(花括号内),派生类对象调用时会调用派生类的重写函数,不会调用被重写函数。重写的基类中被重写的函数必须有virtual修饰。(虚函数/纯虚函数)
#include<bits/stdC++.h>
using namespace std;
class A
{
public:
virtual void fun()
{
cout << "A";
}
};
class B :public A
{
public:
virtual void fun()
{
cout << "B";
}
};
int main(void)
{
A* a = new B();
a->fun();//输出B,A类中的fun在B类中重写
}
2、重载
我们在平时写代码中会用到几个函数但是他们的实现功能相同,但是有些细节却不同。例如:交换两个数的值其中包括(int, float,char,double)这些个类型。在C语言中我们是利用不同的函数名来加以区分。这样的代码不美观而且给程序猿也带来了很多的不便。于是在C++中人们提出了用一个函数名定义多个函数,也就是所谓的函数重载。函数重载是指同一可访问区内被声明的几个具有不同参数列(参数的类型,个数,顺序不同)的同名函数,根据参数列表确定调用哪个函数,重载不关心函数返回类型。
#include<bits/stdC++.h>
using namespace std;
class A
{
void fun() {};
void fun(int i) {};
void fun(int i, int j) {};
void fun1(int i,int j){};
};
3.4 C++如何实现重写与重载的?在C语言中怎么实现函数重载?
答1:重写:在基类的函数前加上virual关键字,就可以在派生类中重写该函数,运行时将会根据对象的实际类型来调用对应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
i. 用virtual关键字申明的函数叫做虚函数,虚函数肯定是类的成员函数
ii. 存在虚函数的类都有一个一维的虚函数表叫做虚表,类的对象有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的
iii. 多态性是一个接口多种实现,是面向对象的核心,分为类的多态性和函数的多态性
iv. 重写用虚函数来实现,结合动态绑定
v. 纯虚函数是虚函数再加上 = 0
vi. 抽象类是指包括至少一个纯虚函数的类
纯虚函数:virtual void fun()=0。即抽象类必须在子类实现这个函数,即先有名称,没有内容,在派生类实现内容。而虚函数可以再子类中不实现。还有,仅含有虚函数的类,并不是抽象类。
答2:重载:C++通过命名倾轧(name mangling)技术来改名函数名,区分参数不同的同名函数,命名倾轧是在编译阶段完成的。
题外话:C++在编译阶段通过函数名+形参列表的方式来区分函数,所以可以实现函数重载。而C语言在编译时是以函数名来区分函数的,所以C与C++混编的时候会出现错误。可以在++代码中使用extern{}关键字来保证正确编译。
答3:c语言中不允许有同名函数,因为编译时函数命名是一样的,不像C++会添加参数类型和返回类型作为函数编译后的名称,进而实现重载。如果要用c语言显现函数重载,可通过以下方式来实现:
- 使用函数指针来实现,重载的函数不能使用同名称,只是类似的实现了函数重载功能
- 重载函数使用可变参数,方式如打开文件open函数
- gcc有内置函数,程序使用编译函数可以实现函数重载
一般方式1比较常用,示例代码:
#include<stdio.h>
void func_int(void * a)
{
printf("%d\n",*(int*)a); //输出int类型,注意 void * 转化为int
}
void func_double(void * b)
{
printf("%.2f\n",*(double*)b);
}
typedef void (*ptr)(void *); //typedef申明一个函数指针
void c_func(ptr p,void *param)
{
p(param); //调用对应函数
}
int main()
{
int a = 23;
double b = 23.23;
c_func(func_int,&a);
c_func(func_double,&b);
return 0;
}
关于typedef和函数指针的恩怨纠葛,在补充代码如下:
//形式1:返回类型(*函数名)(参数列表)
#include <iostream>
using namespace std;
//定义一个函数指针pFUN,它指向一个返回类型为char,有一个整型的参数的函数
char (*pFun)(int);
//定义一个返回类型为char,参数为int的函数
//从指针层面上理解该函数,即函数的函数名实际上是一个指针,
//该指针指向函数在内存中的首地址
char glFun(int a)
{
cout << a;
//return a;
}
int main()
{
//将函数glFun的地址赋值给变量pFun
pFun = glFun;
//*pFun”显然是取pFun所指向地址的内容,当然也就是取出了函数glFun()的内容,然后给定参数为2。
(*pFun)(2);
return 0;
}
//形式2 typedef 返回类型(*新类型)(参数表)
// typedef可以让函数指针更直观方便 ,typedef是新定义了一种类型
typedef char (*PTRFUN)(int);
PTRFUN pFun;
char glFun(int a){ return;}
void main()
{
pFun = glFun;
(*pFun)(2);
}
对于typedef void(*T)(void *),我们进行一层层的剖析
1、首先写成 void(*T)() 可以看出 T是一个指针,是一个指向返回值为void ,参数为空的类型的函数指针
2、在加上(void *) 为 void(*T)(void *) 就变成了一个指向 返回值为void,参数为void *类型的指针
3、在加上上typedef ,加上后,T就不再是一个函数指针了,它代表着一种类型,这种类型可以定义一个 指向 返回值为void,参数为void *类型的指针
4、比如 T t;
void func(void \*);
t=func
3.5 构造函数分为哪几种?
默认无参构造函数、拷贝构造函数、有参构造函数(初始化列表或函数体内赋值)、,font color=‘red’>移动构造函数
需要注意:有参构造中,可细分为初始化列表he函数体内赋值。初始化列表就是在定义变量的同时就给值,函数体内赋值相当于先定义变量,再给值。当类中有成员变量为常量时,必须使用初始化列表。
下面以代码举例,说明拷贝构造函数和移动构造函数:
//拷贝构造函数
Test(const Test& t)
{
this->i = t.i;
this->p = new int(*t.p);
}
//移动构造函数
class Example{
private:
string *ptr;
public:
//移动构造函数
Example (Example&& x) : ptr(x.ptr)
{
x.ptr = nullptr;
}
//拷贝构造函数 深拷贝 区别在于不会处理形参元素x
Example (Example& x)
{
delete ptr;
ptr=new string(x.ptr);
}
//有参构造函数
Example (const string& str) : ptr(new string(str)) {}
//重载赋值运算符
Example& operator= (Example6&& x)
{
delete ptr;
ptr = x.ptr;
x.ptr=nullptr;
return *this;
}
//打印字符串
const string& content() const {return *ptr;}
//重载加号运算符:
Example operator+(const Example& rhs)
{
return Example(content()+rhs.content());
}
};
3.6 只定义析构函数,会自动生成哪些构造函数
只定义了析构函数,编译器将自动为我们生成拷贝构造函数和默认构造函数。
注意:有了有参的构造了,编译器就不提供默认的构造函数。
3.7 说说一个类,默认会生成哪些函数
定义了一个空类,默认为生成以下几个函数:
1、无参构造函数
2、拷贝构造函数(默认的为浅拷贝)
3、重载赋值运算符
4、析构函数(非虚)
3.8 说说 C++ 类对象的初始化顺序,有多重继承情况下的顺序
-
创建派生类的对象,基类的构造函数优先被调用(也优先于派生类里的成员类);
-
如果类里面有成员类,成员类的构造函数优先被调用;(也优先于该类本身的构造函数)
-
基类构造函数如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序而不是它们在成员初始化表中的顺序;;(即:class 类名:public 基类1,public 基类2)
-
成员类对象构造函数如果有多个成员类对象,则构造函数的调用顺序是对象在类中被声明的顺序而不是它们出现在成员初始化表中的顺序;(即:类内变量:类名 变量1;类名 变量2)
-
派生类构造函数,作为一般规则派生类构造函数应该不能直接向一个基类数据成员赋值而是把值传递给适当的基类构造函数,否则两个类的实现变成紧耦合的(tightly coupled)将更加难于正确地修改或扩展基类的实现。(基类设计者的责任是提供一组适当的基类构造函数)
综上可以得出,初始化顺序:
父类构造函数–>成员类对象构造函数–>自身构造函数
其中成员变量的初始化与声明顺序有关,构造函数的调用顺序是类派生列表中的顺序。
析构顺序和构造顺序相反。
代码示例:
#include <iostream>
using namespace std;
class Base1 {
public:
Base1(int i)
{
cout << "construting Base1——" << i << endl;
}
Base1(Base1& b1)
{
cout << "Base1 Copy constructor" << endl;
}
~Base1()
{
cout << "~Base1" << endl;
}
};
class Base2
{
public:
Base2(int j)
{
cout << "constructing Base2——" << j << endl;
}
Base2(Base2& b2)
{
cout << "Base2 Copy constructor" << endl;
}
~Base2()
{
cout << "~Base2" << endl;
}
};
class Derived :public Base1, public Base2
{
public:
//Derived(int a, int b, int c, int d) :Base1(b), member2(c), Base2(a), member1(d)
Derived(int a, int b, int c, int d) :Base1(a), member2(b), Base2(c), member1(d)
{
cout << "constructing Derived" << endl;
}
Derived(Derived& d) :Base1(d), member2(d), Base2(d), member1(d)
{
cout << "Derived Copy constructor " << endl;
}
~Derived()
{
cout << "~Derived" << endl;
}
private:
Base2 member2;
Base1 member1;
};
//在派生类中的,基类构造函数的调用顺序是,先看权限继承方式上的顺序:public Base1, public Base2;
//再看成员变量定义的顺序:Base2 member2; Base1 member1;
int test()
{
Derived d1(1, 2, 3, 4);
Derived d2(d1);
return 0;
}
int main()
{
test();
system("pause");
return 0;
}
//输出结果为:
construting Base1——1
constructing Base2——3
constructing Base2——2
construting Base1——4
constructing Derived
Base1 Copy constructor
Base2 Copy constructor
Base2 Copy constructor
Base1 Copy constructor
Derived Copy constructor
~Derived
~Base1
~Base2
~Base2
~Base1
~Derived
~Base1
~Base2
~Base2
~Base1
3.9 简述下向上转型和向下转型
-
子类转换为父类:向上转型,使用dynamic_cast(expression),这种转换相对来说比较安全不会有数据的丢失;
-
父类转换为子类:向下转型,可以使用强制转换,这种转换时不安全的,会导致数据的丢失,原因是父类的指针或者引用的内存中可能不包含子类的成员的内存。
3.10 简述下深拷贝和浅拷贝,如何实现深拷贝
-
浅拷贝:又称值拷贝,将源对象的值拷贝到目标对象中去,本质上来说源对象和目标对象共用一份实体,只是所引用的变量名不同,地址其实还是相同的。举个简单的例子,你的小名叫西西,大名叫冬冬,当别人叫你西西或者冬冬的时候你都会答应,这两个名字虽然不相同,但是都指的是你。
-
深拷贝,拷贝的时候先开辟出和源对象大小一样的空间,然后将源对象里的内容拷贝到目标对象中去,这样两个指针就指向了不同的内存位置。并且里面的内容是一样的,这样不但达到了我们想要的目的,还不会出现问题,两个指针先后去调用析构函数,分别释放自己所指向的位置。即为每次增加一个指针,便申请一块新的内存,并让这个指针指向新的内存,深拷贝情况下,不会出现重复释放同一块内存的错误。
3.11 简述一下C++中的多态
由于派生类重写基类方法,然后用基类引用指向派生类对象,调用方法时候会进行动态绑定,这就是多态。 多态分为静态多态和动态多态:
- 静态多态:编译器在编译期间完成的,编译器会根据实参类型来推断该调用哪个函数,如果有对应的函数,就调用,没有则在编译时报错。例如:
include<iostream>
using namespace std;
int Add(int a,int b)//1
{
return a+b;
}
char Add(char a,char b)//2
{
return a+b;
}
int main()
{
cout<<Add(666,888)<<endl;//1
cout<<Add('1','2');//2
return 0;
}
静态多态,就是在编译阶段就实现的多态。
2. 动态多态:其实要实现动态多态,需要几个条件----即动态绑定条件:
虚函数,基类必须含有虚函数,派生类必须重写虚函数;
通过基类指针或引用来调用虚函数
3.12 为什么要虚析构,为什么不能虚构造?
- 为什么要虚析构:将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们new一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏。如果基类的析构函数不是虚函数,在特定情况下会导致派生类无法被析构。
i. 用派生类类型指针绑定派生类实例,析构的时候,不管基类析构函数是不是虚函数,都会正常析构
ii. 用基类类型指针绑定派生类实例,析构的时候,如果基类析构函数不是虚函数,则只会析构基类,不会析构派生类对象,从而造成内存泄漏。为什么会出现这种现象呢,个人认为析构的时候如果没有虚函数的动态绑定功能,就只根据指针的类型来进行的,而不是根据指针绑定的对象来进行,所以只是调用了基类的析构函数;如果基类的析构函数是虚函数,则析构的时候就要根据指针绑定的对象来调用对应的析构函数了。
C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此C++默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。
- 为什么不能虚构造?
i. 从存储空间角度:虚函数对应一个vtable,这个表的地址是存储在对象的内存空间的。如果将构造函数设置为虚函数,就需要到vtable 中调用,可是对象还没有实例化,没有内存空间分配,如何调用。(悖论)
ii. 从使用角度:虚函数主要用于在信息不全的情况下,能使重载的函数得到对应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。所以构造函数没有必要是虚函数。虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
iii. 从实现上看,vtable 在构造函数调用后才建立,因而构造函数不可能成为虚函数。从实际含义上看,在调用构造函数时还不能确定对象的真实类型(因为子类会调父类的构造函数);而且构造函数的作用是提供初始化,在对象生命期只执行一次,不是对象的动态行为,也没有太大的必要成为虚函数。
3.13 说说模板类是在什么时候实现的
-
模板实例化:模板的实例化分为显式实例化和隐式实例化,前者是研发人员明确的告诉模板应该使用什么样的类型去生成具体的类或函数,后者是在编译的过程中由编译器来决定使用什么类型来实例化一个模板。不管是显式实例化或隐式实例化,最终生成的类或函数完全是按照模板的定义来实现的
-
模板具体化:当模板使用某种类型实例化后生成的类或函数不能满足需要时,可以考虑对模板进行具体化。具体化时可以修改原模板的定义,当使用该类型时,按照具体化后的定义实现,具体化相当于对某种类型进行特殊处理。模板的具体化,就是重写模板>
代码示例:
#include <iostream>
using namespace std;
// #1 模板定义
template<class T>
struct TemplateStruct
{
TemplateStruct()
{
cout << sizeof(T) << endl;
}
};
// #2 模板显示实例化
template struct TemplateStruct<int>;
// #3 模板具体化
template<> struct TemplateStruct<double>
{
TemplateStruct() {
cout << "--8--" << endl;
}
};
int main()
{
TemplateStruct<int> intStruct;
TemplateStruct<double> doubleStruct;
// #4 模板隐式实例化
TemplateStruct<char> llStruct;
}
//输出结果为:
4
--8--
1
3.14 说说类继承时,派生类对不同关键字修饰的基类方法的访问权限
类中的成员可以分为三种类型,分别为public成员、protected成员、public成员。类中可以直接访问自己类的public、protected、private成员,但类对象只能访问自己类的public成员。
-
public继承:派生类可以访问基类的public、protected成员,不可以访问基类的private成员;
派生类对象可以访问基类的public成员,不可以访问基类的protected、private成员。 -
protected继承:派生类可以访问基类的public、protected成员,不可以访问基类的private成员;
派生类对象不可以访问基类的public、protected、private成员。 -
private继承:派生类可以访问基类的public、protected成员,不可以访问基类的private成员;
派生类对象不可以访问基类的public、protected、private成员。
3.15 简述一下移动构造函数,什么库用到了这个函数?
C++11中新增了移动构造函数。与拷贝类似,移动也使用一个对象的值设置另一个对象的值。但是,又与拷贝不同的是,移动实现的是对象值真实的转移(源对象到目的对象):源对象将丢失其内容,其内容将被目的对象占有。移动操作的发生的时候,是当移动值的对象是未命名的对象的时候。这里未命名的对象就是那些临时变量,甚至都不会有名称。典型的未命名对象就是函数的返回值或者类型转换的对象。使用临时对象的值初始化另一个对象值,不会要求对对象的复制:因为临时对象不会有其它使用,因而,它的值可以被移动到目的对象。做到这些,就要使用移动构造函数和移动赋值:当使用一个临时变量对对象进行构造初始化的时候,调用移动构造函数。类似的,使用未命名的变量的值赋给一个对象时,调用移动赋值操作。
移动操作的概念对对象管理它们使用的存储空间很有用的,诸如对象使用new和delete分配内存的时候。在这类对象中,拷贝和移动是不同的操作:从A拷贝到B意味着,B分配了新内存,A的整个内容被拷贝到为B分配的新内存上。
而从A移动到B意味着分配给A的内存转移给了B,没有分配新的内存,它仅仅包含简单地拷贝指针。
看下面的例子:
// 移动构造函数和赋值
#include <iostream>
#include <string>
using namespace std;
class Example6 {
private:
string* ptr;
public:
Example6 (const string& str) : ptr(new string(str)) {}
~Example6 () {delete ptr;}
// 移动构造函数,参数x不能是const Pointer&& x,
// 因为要改变x的成员数据的值;
// C++98不支持,C++0x(C++11)支持
Example6 (Example6&& x) : ptr(x.ptr)
{
x.ptr = nullptr;
}
// move assignment
Example6& operator= (Example6&& x)
{
delete ptr;
ptr = x.ptr;
x.ptr=nullptr;
return *this;
}
// access content:
const string& content() const {return *ptr;}
// addition:
Example6 operator+(const Example6& rhs)
{
return Example6(content()+rhs.content());
}
};
int main () {
Example6 foo("Exam"); // 构造函数
Example6 bar(move(foo)); // 移动构造函数
// 调用move之后,foo变为一个右值引用变量,
// 此时,foo所指向的字符串已经被"掏空",
// 所以此时不能再调用foo
Example6 bar2 = Example6("ple"); // 拷贝构造函数
bar = bar+ bar2; // 移动赋值,在这儿"="号右边的加法操作,
// 产生一个临时值,即一个右值
// 所以此时调用移动赋值语句
cout << "bar's content: " << bar.content() << '\n';
return 0;
}
//说明:std::move()函数时C++11新增的,用于将一个左值引用转换为右值引用。
//输出结果为:bar`s content: Example
//附加说明:本例中,如何调用重载赋值运算符:Example6 str2=move(bar);此时,bar字符串被删除内存,str2=Example
3.16 请你回答一下 C++ 类内可以定义引用数据成员吗?
C++类内可以定义引用成员变量,但要遵循以下三个规则:
-
不能用默认构造函数初始化,必须提供构造函数,用初始化列表的方式来初始化引用成员变量。否则会造成引用未初始化错误。
-
构造函数的形参也必须是引用类型。
-
不能在构造函数里初始化,必须在初始化列表中进行初始化。
3.17 简述一下什么是常函数,有什么作用
类的成员函数后面加 const,表明这个函数不会对这个类对象的数据成员(准确地说是非静态数据成员)作任何改变。在设计类的时候,一个原则就是对于不改变数据成员的成员函数都要在后面加const,而对于改变数据成员的成员函数不能加 const。所以 const 关键字对成员函数的行为作了更明确的限定:有 const 修饰的成员函数(指 const 放在函数参数表的后面,而不是在函数前面或者参数表内),只能读取数据成员,不能改变数据成员;没有 const 修饰的成员函数,对数据成员则是可读可写的。除此之外,在类的成员函数后面加 const 还有什么好处呢?那就是常量(即 const)对象可以调用 const 成员函数,而不能调用非const修饰的函数。正如非const类型的数据可以给const类型的变量赋值一样,反之则不成立。
#include<iostream>
using namespace std;
class CStu
{
public:
int a;
CStu()
{
a = 12;
}
void Show() const
{
//a = 13; //常函数不能修改数据成员
cout <<a << "I am show()" << endl;
}
};
int main()
{
CStu st;
st.Show();
system("pause");
return 0;
}
3.18 说说什么是虚继承,解决什么问题,如何实现?
虚继承是解决C++多重继承问题的一种手段,从不同途径继承来的同一基类,会在子类中存在多份拷贝。这将存在两个问题:其一,浪费存储空间;第二,存在二义性问题,通常可以将派生类对象的地址赋值给基类对象,实现的具体方式是,将基类指针指向继承类(继承类有基类的拷贝)中的基类对象的地址,但是多重继承可能存在一个基类的多份拷贝,这就出现了二义性。虚继承可以解决多种继承前面提到的两个问题(即菱形继承问题)
#include<iostream>
using namespace std;
class A{
public:
int _a;
};
class B :virtual public A
{
public:
int _b;
};
class C :virtual public A
{
public:
int _c;
};
class D :public B, public C
{
public:
int _d;
};
//菱形继承和菱形虚继承的对象模型
int main()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
cout << sizeof(D) << endl;
return 0;
}
分别从菱形继承和虚继承来分析:
菱形继承中A在B,C,D,中各有一份,虚继承中,A共享。
上面的虚继承表实际上是一个指针数组。B、C实际上是虚基表指针,指向虚基表。
虚基表:存放相对偏移量,用来找虚基类
3.19 简述一下虚函数和纯虚函数,以及实现原理
- C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。如果调用非虚函数,则无论实际对象是什么类型,都执行基类类型所定义的函数。非虚函数总是在编译时根据调用该函数的对象,引用或指针的类型而确定。如果调用虚函数,则直到运行时才能确定调用哪个函数,运行的虚函数是引用所绑定或指针所指向的对象所属类型定义的版本。虚函数必须是基类的非静态成员函数。虚函数的作用是实现动态联编,也就是在程序的运行阶段动态地选择合适的成员函数,在定义了虚函数后,可以在基类的派生类中对虚函数重新定义,在派生类中重新定义的函数应与虚函数具有相同的形参个数和形参类型。以实现统一的接口,不同定义过程。如果在派生类中没有对虚函数重新定义,则它继承其基类的虚函数。
class Person{
public:
//虚函数
virtual void GetName(){
cout<<"PersonName:xiaosi"<<endl;
};
};
class Student:public Person{
public:
void GetName(){
cout<<"StudentName:xiaosi"<<endl;
};
};
int main(){
//指针
Person *person = new Student();
//基类调用子类的函数
person->GetName();//StudentName:xiaosi
}
虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得尤为重要了,它就像一个地图一样,指明了实际所应该调用的函数。
- 纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0” virtualvoid GetName() =0。在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。为了解决上述问题,将函数定义为纯虚函数,则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。将函数定义为纯虚函数能够说明,该函数为后代类型提供了可以覆盖的接口,但是这个类中的函数绝不会调用。声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。必须在继承类中重新声明函数(不要后面的=0)否则该派生类也不能实例化,而且它们在抽象类中往往没有定义。定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。
//抽象类
class Person{
public:
//纯虚函数
virtual void GetName()=0;
};
class Student:public Person{
public:
Student(){
};
void GetName(){
cout<<"StudentName:xiaosi"<<endl;
};
};
int main(){
Student student;
}
3.20 说说纯虚函数能实例化吗,为什么?派生类要实现吗,为什么?
- 纯虚函数不可以实例化,但是可以用其派生类实例化,示例如下:
class Base
{
public:
virtual void func() = 0;
};
#include<iostream>
using namespace std;
class Base
{
public:
virtual void func() = 0;
};
class Derived :public Base
{
public:
void func() override
{
cout << "哈哈" << endl;
}
};
int main()
{
Base *b = new Derived();
b->func();
return 0;
}
-
虚函数的原理采用 vtable。类中含有纯虚函数时,其vtable 不完全,有个空位。
即“纯虚函数在类的vtable表中对应的表项被赋值为0。也就是指向一个不存在的函数。由于编译器绝对不允许有调用一个不存在的函数的可能,所以该类不能生成对象。在它的派生类中,除非重写此函数,否则也不能生成对象。”所以纯虚函数不能实例化。
-
纯虚函数是在基类中声明的虚函数,它要求任何派生类都要定义自己的实现方法,以实现多态性。
-
定义纯虚函数是为了实现一个接口,用来规范派生类的行为,也即规范继承这个类的程序员必须实现这个函数。派生类仅仅只是继承函数的接口。纯虚函数的意义在于,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但基类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。
3.21 说说C++中虚函数与纯虚函数的区别
答:
-
虚函数和纯虚函数可以定义在同一个类中,含有纯虚函数的类被称为抽象类,而只含有虚函数的类不能被称为抽象类。
-
虚函数可以被直接使用,也可以被子类重载以后,以多态的形式调用,而纯虚函数必须在子类中实现该函数才可以使用,因为纯虚函数在基类有声明而没有定义。
-
虚函数和纯虚函数都可以在子类中被重载,以多态的形式被调用。
-
虚函数和纯虚函数通常存在于抽象基类之中,被继承的子类重载,目的是提供一个统一的接口。
-
虚函数的定义形式:
virtual{}
;纯虚函数的定义形式:virtual {} = 0
;在虚函数和纯虚函数的定义中不能有static标识符,原因很简单,被static修饰的函数在编译时要求前期绑定,然而虚函数却是动态绑定,而且被两者修饰的函数生命周期也不一样。
举个虚函数的例子:
class A
{
public:
virtual void foo()
{
cout<<"A::foo() is called"<<endl;
}
};
class B:public A
{
public:
void foo()
{
cout<<"B::foo() is called"<<endl;
}
};
int main(void)
{
A *a = new B();
a->foo(); // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!
return 0;
}
这个例子是虚函数的一个典型应用,通过这个例子,也许你就对虚函数有了一些概念。它虚就虚在所谓“推迟联编”或者“动态联编”上,一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为“虚”函数。
虚函数只能借助于指针或者引用来达到多态的效果。
- 纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0” 。形如
virtual void funtion1()=0
为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。
为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;),则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。
声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。
纯虚函数最显著的特征是:它们必须在继承类中重新声明函数(不要后面的=0,否则该派生类也不能实例化),而且它们在抽象类中往往没有定义。
定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。
纯虚函数的意义,让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。
3.22 说说 C++ 中什么是菱形继承问题,如何解决
使用虚继承来解决该问题
class Tiger : virtual public Animal { /* ... */ };
class Lion : virtual public Animal { /* ... */ }
3.23 请问构造函数中的能不能调用虚方法
不要在构造函数中调用虚方法,从语法上讲,调用完全没有问题,但是从效果上看,往往不能达到需要的目的。
派生类对象构造期间进入基类的构造函数时,对象类型变成了基类类型,而不是派生类类型。
同样,进入基类析构函数时,对象也是基类类型。
所以,虚函数始终仅仅调用基类的虚函数(如果是基类调用虚函数),不能达到多态的效果,所以放在构造函数中是没有意义的,而且往往不能达到本来想要的效果。
3.24 请问拷贝构造函数的参数是什么传递方式,为什么
-
拷贝构造函数的参数必须使用引用传递
-
如果拷贝构造函数中的参数不是一个引用,即形如CClass(const CClass c_class),那么就相当于采用了传值的方式(pass-by-value),而传值的方式会调用该类的拷贝构造函数,从而造成无穷递归地调用拷贝构造函数。因此拷贝构造函数的参数必须是一个引用。
需要澄清的是,传指针其实也是传值,如果上面的拷贝构造函数写成CClass(const CClass* c_class),也是不行的。事实上,只有传引用不是传值外,其他所有的传递方式都是传值。
3.25 如何理解抽象类?
- 抽象类的定义如下:
纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”,有虚函数的类就叫做抽象类。
- 抽象类有如下几个特点:
1)抽象类只能用作其他类的基类,不能建立抽象类对象。
2)抽象类不能用作参数类型、函数返回类型或显式转换的类型。
3)可以定义指向抽象类的指针和引用,此指针可以指向它的派生类,进而实现多态性。
3.26 什么是多态?除了虚函数,还有什么方式能实现多态?
-
多态是面向对象的重要特性之一,它是一种行为的封装,就是不同对象对同一行为会有不同的状态。(举例 : 学生和成人都去买票时,学生会打折,成人不会)
-
多态是以封装和继承为基础的。在C++中多态分为静态多态(早绑定)和动态多态(晚绑定)两种,其中动态多态是通过虚函数实现,静态多态通过函数重载实现,代码如下:
class A
{
public:
void do(int a);
void do(int a, int b);
};
3.27 简述一下虚析构函数,什么作用
- 虚析构函数,是将基类的析构函数声明为virtual,举例如下:
class TimeKeeper
{
public:
TimeKeeper() {}
virtual ~TimeKeeper() {}
};
- 虚析构函数的主要作用是防止内存泄露。
定义一个基类的指针p,在delete p时,如果基类的析构函数是虚函数,这时只会看p所赋值的对象,如果p赋值的对象是派生类的对象,就会调用派生类的析构函数(毫无疑问,在这之前也会先调用基类的构造函数,在调用派生类的构造函数,然后调用派生类的析构函数,基类的析构函数,所谓先构造的后释放);如果p赋值的对象是基类的对象,就会调用基类的析构函数,这样就不会造成内存泄露。
如果基类的析构函数不是虚函数,在delete p时,调用析构函数时,只会看指针的数据类型,而不会去看赋值的对象,这样就会造成内存泄露。
说明:
我们创建一个TimeKeeper基类和一些及其它的派生类作为不同的计时方法:
class TimeKeeper
{
public:
TimeKeeper() {}
~TimeKeeper() {} //非virtual的
};
//都继承与TimeKeeper
class AtomicClock :public TimeKeeper{};
class WaterClock :public TimeKeeper {};
class WristWatch :public TimeKeeper {};
如果客户想要在程序中使用时间,不想操作时间如何计算等细节,这时候我们可以设计factory(工厂)函数,让函数返回指针指向一个计时对象。该函数返回一个基类指针,这个基类指针是指向于派生类对象的
TimeKeeper* getTimeKeeper()
{
//返回一个指针,指向一个TimeKeeper派生类的动态分配对象
}
因为函数返回的对象存在于堆中,因此为了在不使用时我们需要使用释放该对象(delete)
TimeKeeper* ptk = getTimeKeeper();
delete ptk;
此处基类的析构函数是非virtual的,因此通过一个基类指针删除派生类对象是错误的
解决办法: 将基类的析构函数改为virtual就正确了
class TimeKeeper
{
public:
TimeKeeper() {}
virtual ~TimeKeeper() {}
};
声明为virtual之后,通过基类指针删除派生类对象就会释放整个对象(基类+派生类)
3.28 说说什么是虚基类,可否被实例化?
- 在被继承的类前面加上virtual关键字,这时被继承的类称为虚基类,代码如下:
class A
class B1:public virtual A;
class B2:public virtual A;
class D:public B1,public B2;
- 虚继承的类可以被实例化,举例如下:
class Animal {/* ... */ };
class Tiger : virtual public Animal { /* ... */ };
class Lion : virtual public Animal { /* ... */ }
int main( )
{
Liger lg ;
/*既然我们已经在Tiger和Lion类的定义中声明了"virtual"关键字,于是下面的代码编译OK */
int weight = lg.getWeight();
}
3.29 简述一下拷贝赋值和移动赋值?
-
拷贝赋值是通过拷贝构造函数来赋值,在创建对象时,使用同一类中之前创建的对象来初始化新创建的对象。
-
移动赋值是通过移动构造函数来赋值,二者的主要区别在于
1)拷贝构造函数的形参是一个左值引用,而移动构造函数的形参是一个右值引用;
2)拷贝构造函数完成的是整个对象或变量的拷贝,而移动构造函数是生成一个指针指向源对象或变量的地址,接管源对象的内存,相对于大量数据的拷贝节省时间和内存空间。
3.30 仿函数了解吗?有什么作用
- 仿函数(functor)又称为函数对象(function object)是一个能行使函数功能的类。仿函数的语法几乎和我们普通的函数调用一样,不过作为仿函数的类,都必须重载operator()运算符,举个例子:
class Func{
public:
void operator() (const string& str) const {
cout<<str<<endl;
}
};
Func myFunc;
myFunc("helloworld!");
//输出:
helloworld!
- 仿函数既能想普通函数一样传入给定数量的参数,还能存储或者处理更多我们需要的有用信息。我们可以举个例子:
假设有一个vector<string>
,你的任务是统计长度小于5的string的个数,如果使用count_if函数的话,你的代码可能长成这样:
bool LengthIsLessThanFive(const string& str) {
return str.length()<5;
}
int res=count_if(vec.begin(), vec.end(), LengthIsLessThanFive);
其中count_if函数的第三个参数是一个函数指针,返回一个bool类型的值。一般的,如果需要将特定的阈值长度也传入的话,我们可能将函数写成这样:
```cpp
bool LenthIsLessThan(const string& str, int len) {
return str.length()<len;
}
这个函数看起来比前面一个版本更具有一般性,但是他不能满足count_if函数的参数要求:count_if要求的是unary function(仅带有一个参数)作为它的最后一个参数。如果我们使用仿函数,是不是就豁然开朗了呢:
class ShorterThan {
public:
explicit ShorterThan(int maxLength) : length(maxLength) {}
bool operator() (const string& str) const {
return str.length() < length;
}
private:
const int length;
};
3.31 C++ 中哪些函数不能被声明为虚函数?
常见的不不能声明为虚函数的有:普通函数(非成员函数),静态成员函数,内联成员函数,构造函数,友元函数。
- 为什么C++不支持普通函数为虚函数?
普通函数(非成员函数)只能被overload(重载),不能被override(重写),声明为虚函数也没有什么意思,因此编译器会在编译时绑定函数。
- 为什么C++不支持构造函数为虚函数?
这个原因很简单,主要是从语义上考虑,所以不支持。因为构造函数本来就是为了明确初始化对象成员才产生的,然而virtual function主要是为了再不完全了解细节的情况下也能正确处理对象。另外,virtual函数是在不同类型的对象产生不同的动作,现在对象还没有产生,如何使用virtual函数来完成你想完成的动作。(这不就是典型的悖论)
构造函数用来创建一个新的对象,而虚函数的运行是建立在对象的基础上,在构造函数执行时,对象尚未形成,所以不能将构造函数定义为虚函数
- 为什么C++不支持内联成员函数为虚函数?
内联函数就是为了在代码中直接展开,减少函数调用花费的代价,而虚函数是为了在继承后对象能够准确的执行自己的动作,这是不可能统一的。(再说了,inline函数在编译时被展开,虚函数在运行时才能动态的绑定函数)
内联函数是在编译时期展开,而虚函数的特性是运行时才动态联编,所以两者矛盾,不能定义内联函数为虚函数
- 为什么C++不支持静态成员函数为虚函数?
这也很简单,静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,他也没有要动态绑定的必要性。
静态成员函数属于一个类而非某一对象,没有this指针,它无法进行对象的判别
- 为什么C++不支持友元函数为虚函数?
因为C++不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法。
3.32 解释下 C++ 中类模板和模板类的区别
-
类模板是模板的定义,不是一个实实在在的类,定义中用到通用类型参数
-
模板类是实实在在的类定义,是类模板的实例化。类定义中参数被实际类型所代替。
答案解析
-
类模板的类型参数可以有一个或多个,每个类型前面都必须加class或typename,如template <class T1,class T2>class someclass{…};在定义对象时分别代入实际的类型名,如 someclass<int,double> obj;
-
和使用类一样,使用类模板时要注意其作用域,只能在其有效作用域内用它定义对象。
-
模板可以有层次,一个类模板可以作为基类,派生出派生模板类。
3.33 虚函数表里存放的内容是什么时候写进去的?
-
虚函数表是一个存储虚函数地址的数组,以NULL结尾。虚表(vtable)在编译阶段生成,对象内存空间开辟以后,写入对象中的 v_ptr,然后调用构造函数。即:虚表在构造函数之前写入。
-
除了在构造函数之前写入之外,我们还需要考虑到虚表的二次写入机制,通过此机制让每个对象的虚表指针都能准确的指向到自己类的虚表,为实现动多态提供支持。
4、标准模板库STL
5、C++新特性
5.1 C++新特性
内容参考于csdn
一、C++新特性
C++新特性主要包括包含语法改进和标准库扩充两个方面,主要包括以下11点
1、语法的改进
1)统一的初始化方法
在 C++11 中,可以直接在变量名后面跟上初始化列表,来进行对象的初始化
2)成员变量默认初始化
3)auto关键字用于定义变量,编译器可以自动判断的类型(前提:定义一个变量时对其进行初始化)
4)decltype 求表达式的类型
5)智能指针 shared_ptr
和 unique_ptr、weak_ptr 不同之处在于,多个 shared_ptr 智能指针可以共同使用同一块堆内存。并且,由于该类型智能指针在实现上采用的是引用计数机制,即便有一个 shared_ptr 指针放弃了堆内存的“使用权”(引用计数减 1),也不会影响其他指向同一堆内存的 shared_ptr 指针(只有引用计数为 0 时,堆内存才会被自动释放)
6)空指针 nullptr(原来NULL)
7)基于范围的for循环
8)右值引用和move语义
- 右值引用
//左值引用
int num = 10;
int &b = num; //正确
int &c = 10; //错误,在C++98/03标准中,无法为右值添加引用
实际开发中可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。为此,C++11 标准新引入了另一种引用方式,称为右值引用,用 “&&” 表示。
int num = 10;
//int && a = num; //右值引用不能初始化为左值
int && a = 10;
- move语义
在C++11中,标准库在中提供了一个有用的函数std::move,std::move()函数并不能移动任何东西,它唯一的功能是将一个左值引用强制转化为右值引用,继而可以通过右值引用使用该值,以用于移动语义。从实现上讲,std::move基本等同于一个类型转换:static_cast<T&&>(lvalue);
move( arg ) //其中,arg 表示指定的左值对象。该函数会返回 arg 对象的右值形式。
2、标准模板库的扩充(往STL里面添加了一些模板类,比较好用)
9)无序容器(哈希表)
用法和功能同map一模一样,区别在于哈希表的效率更高
10)正则表达式
可以认为正则表达式实质上是一个字符串,该字符串描述了一种特定模式的字符串
11)Lambda表达式(匿名函数)
lambda表达式是一个匿名函数,用于定义并创建匿名的函数对象,以简化编程工作。举个例子:
vector<int> vec;
sort(vec.begin(), vec.end(), cmp); // 旧式
sort(vec.begin(), vec.end(), [](int a, int b) -> bool { return a < b; }); // Lambda表达式
声明lambda表达式:
[capture list] (params list) mutable exception-> return type { function body }
- capture list:捕获外部变量列表
- params list:形参列表
- mutable指示符:用来说用是否可以修改捕获的变量
- exception:异常设定
- return type:返回类型
- function body:函数体
详细说明:
- 统一的初始化方法
C++98/03 可以使用初始化列表(initializer list)进行初始化:
int i_arr[3] = { 1, 2, 3 };
long l_arr[] = { 1, 3, 2, 4 };
struct A
{
int x;
int y;
} a = { 1, 2 };
但是这种初始化方式的适用性非常狭窄,只有上面提到的这两种数据类型可以使用初始化列表。在 C++11 中,初始化列表的适用性被大大增加了。它现在可以用于任何类型对象的初始化,实例如下:
class Foo
{
public:
Foo(int) {}
private:
Foo(const Foo &);
};
int main(void)
{
Foo a1(123);
Foo a2 = 123; //error: 'Foo::Foo(const Foo &)' is private
Foo a3 = { 123 };
Foo a4 { 123 };
int a5 = { 3 };
int a6 { 3 };
return 0;
}
在上例中,a3、a4 使用了新的初始化方式来初始化对象,效果如同 a1 的直接初始化。a5、a6 则是基本数据类型的列表初始化方式。可以看到,它们的形式都是统一的。这里需要注意的是,a3 虽然使用了等于号,但它仍然是列表初始化,因此,私有的拷贝构造并不会影响到它。a4 和 a6 的写法,是 C++98/03 所不具备的。在 C++11 中,可以直接在变量名后面跟上初始化列表,来进行对象的初始化。
- 成员变量默认初始化
好处:构建一个类的对象不需要用构造函数初始化成员变量。
//程序实例
#include<iostream>
using namespace std;
class B
{
public:
int m = 1234; //成员变量有一个初始值
int n;
};
int main()
{
B b;
cout << b.m << endl;
return 0;
}
- auto关键字
用于定义变量,编译器可以自动判断的类型(前提:定义一个变量时对其进行初始化)。
//程序实例
#include <vector>
using namespace std;
int main(){
vector< vector<int> > v;
vector< vector<int> >::iterator i = v.begin();
return 0;
}
可以看出来,定义迭代器 i 的时候,类型书写比较冗长,容易出错。然而有了 auto 类型推导,我们大可不必这样,只写一个 auto 即可。
- decltype 求表达式的类型
decltype 是 C++11 新增的一个关键字,它和 auto 的功能一样,都用来在编译时期进行自动类型推导。
(1)为什么要有decltype
因为 auto 并不适用于所有的自动类型推导场景,在某些特殊情况下 auto 用起来非常不方便,甚至压根无法使用,所以 decltype 关键字也被引入到 C++11 中。
auto 和 decltype 关键字都可以自动推导出变量的类型,但它们的用法是有区别的:
auto varname = value;
decltype(exp) varname = value;
其中,varname 表示变量名,value 表示赋给变量的值,exp 表示一个表达式。
auto 根据"=“右边的初始值 value 推导出变量的类型,而 decltype 根据 exp 表达式推导出变量的类型,跟”="右边的 value 没有关系。
另外,auto 要求变量必须初始化,而 decltype 不要求。这很容易理解,auto 是根据变量的初始值来推导出变量类型的,如果不初始化,变量的类型也就无法推导了。decltype 可以写成下面的形式:
decltype(exp) varname;
(2)代码示例
// decltype 用法举例
nt a = 0;
decltype(a) b = 1; //b 被推导成了 int
decltype(10.8) x = 5.5; //x 被推导成了 double
decltype(x + 100) y; //y 被推导成了 double
- 智能指针 shared_ptr
和 unique_ptr、weak_ptr 不同之处在于,多个 shared_ptr 智能指针可以共同使用同一块堆内存。并且,由于该类型智能指针在实现上采用的是引用计数机制,即便有一个 shared_ptr 指针放弃了堆内存的“使用权”(引用计数减 1),也不会影响其他指向同一堆内存的 shared_ptr 指针(只有引用计数为 0 时,堆内存才会被自动释放)。
#include <iostream>
#include <memory>
using namespace std;
int main()
{
//构建 2 个智能指针
std::shared_ptr<int> p1(new int(10));
std::shared_ptr<int> p2(p1);
//输出 p2 指向的数据
cout << *p2 << endl;
p1.reset();//引用计数减 1,p1为空指针
if (p1) {
cout << "p1 不为空" << endl;
}
else {
cout << "p1 为空" << endl;
}
//以上操作,并不会影响 p2
cout << *p2 << endl;
//判断当前和 p2 同指向的智能指针有多少个
cout << p2.use_count() << endl;
return 0;
}
/* 程序运行结果:
10
p1 为空
10
1
*/
- 空指针 nullptr(原来NULL)
nullptr 是 nullptr_t 类型的右值常量,专用于初始化空类型指针。nullptr_t 是 C++11 新增加的数据类型,可称为“指针空值类型”。也就是说,nullpter 仅是该类型的一个实例对象(已经定义好,可以直接使用),如果需要我们完全定义出多个同 nullptr 完全一样的实例对象。值得一提的是,nullptr 可以被隐式转换成任意的指针类型。例如:
int * a1 = nullptr;
char * a2 = nullptr;
double * a3 = nullptr;
显然,不同类型的指针变量都可以使用 nullptr 来初始化,编译器分别将 nullptr 隐式转换成 int、char 以及 double* 指针类型。另外,通过将指针初始化为 nullptr,可以很好地解决 NULL 遗留的问题,比如:
#include <iostream>
using namespace std;
void isnull(void *c){
cout << "void*c" << endl;
}
void isnull(int n){
cout << "int n" << endl;
}
int main() {
isnull(NULL);
isnull(nullptr);
return 0;
}
/* 程序运行结果:
int n
void*c
*/
- 基于范围的for循环
如果要用 for 循环语句遍历一个数组或者容器,只能套用如下结构:
for(表达式 1; 表达式 2; 表达式 3){
//循环体
}
//程序实例
#include <iostream>
#include <vector>
#include <string.h>
using namespace std;
int main() {
char arc[] = "www.123.com";
int i;
//for循环遍历普通数组
for (i = 0; i < strlen(arc); i++) {
cout << arc[i];
}
cout << endl;
vector<char>myvector(arc,arc+3);
vector<char>::iterator iter;
//for循环遍历 vector 容器
for (iter = myvector.begin(); iter != myvector.end(); ++iter) {
cout << *iter;
}
return 0;
}
/* 程序运行结果:
www.123.com
www
*/
- 右值引用和move语义
i. 右值引用
C++98/03 标准中就有引用,使用 “&” 表示。但此种引用方式有一个缺陷,即正常情况下只能操作 C++ 中的左值,无法对右值添加引用。举个例子:
int num = 10;
int &b = num; //正确
int &c = 10; //错误
如上所示,编译器允许我们为 num 左值建立一个引用,但不可以为 10 这个右值建立引用。因此,C++98/03 标准中的引用又称为左值引用。
注意,虽然 C++98/03 标准不支持为右值建立非常量左值引用,但允许使用常量左值引用操作右值。也就是说,常量左值引用既可以操作左值,也可以操作右值,例如:
int num = 10;
const int &b = num;
const int &c = 10;
我们知道,右值往往是没有名称的,因此要使用它只能借助引用的方式。这就产生一个问题,实际开发中我们可能需要对右值进行修改(实现移动语义时就需要),显然左值引用的方式是行不通的。
为此,C++11 标准新引入了另一种引用方式,称为右值引用,用 “&&” 表示。
需要注意的,和声明左值引用一样,右值引用也必须立即进行初始化操作,且只能使用右值进行初始化,比如:
int num = 10;
//int && a = num; //右值引用不能初始化为左值
int && a = 10;
和常量左值引用不同的是,右值引用还可以对右值进行修改。例如:
int && a = 10;
a = 100;
cout << a << endl;
/* 程序运行结果:
100
*/
另外值得一提的是,C++ 语法上是支持定义常量右值引用的,例如:
const int&& a = 10;//编译器不会报错
但这种定义出来的右值引用并无实际用处。一方面,右值引用主要用于移动语义和完美转发,其中前者需要有修改右值的权限;其次,常量右值引用的作用就是引用一个不可修改的右值,这项工作完全可以交给常量左值引用完成。
ii. move语义
move 本意为 “移动”,但该函数并不能移动任何数据,它的功能很简单,就是将某个左值强制转化为右值。基于 move() 函数特殊的功能,其常用于实现移动语义。move() 函数的用法也很简单,其语法格式如下:
move( arg ) //其中,arg 表示指定的左值对象。该函数会返回 arg 对象的右值形式。
//程序实例
#include <iostream>
using namespace std;
class first {
public:
first() :num(new int(0)) {
cout << "construct!" << endl;
}
//移动构造函数
first(first &&d) :num(d.num) {
d.num = NULL;
cout << "first move construct!" << endl;
}
public: //这里应该是 private,使用 public 是为了更方便说明问题
int *num;
};
class second {
public:
second() :fir() {}
//用 first 类的移动构造函数初始化 fir
second(second && sec) :fir(move(sec.fir)) {
cout << "second move construct" << endl;
}
public: //这里也应该是 private,使用 public 是为了更方便说明问题
first fir;
};
int main() {
second oth;
second oth2 = move(oth);
//cout << *oth.fir.num << endl; //程序报运行时错误
return 0;
}
/* 程序运行结果:
construct!
first move construct!
second move construct
*/
- 无序容器(哈希表)
用法和功能同map一模一样,区别在于哈希表的效率更高。
(1) 无序容器具有以下 2 个特点:
a. 无序容器内部存储的键值对是无序的,各键值对的存储位置取决于该键值对中的键,
b. 和关联式容器相比,无序容器擅长通过指定键查找对应的值(平均时间复杂度为 O(1));但对于使用迭代器遍历容器中存储的元素,无序容器的执行效率则不如关联式容器。
(2) 和关联式容器一样,无序容器只是一类容器的统称,其包含有 4 个具体容器,分别为 unordered_map、unordered_multimap、unordered_set 以及 unordered_multiset。功能如下表:
无序容器 | 功能 |
---|---|
unordered_map | 存储键值对 <key, value> 类型的元素,其中各个键值对键的值不允许重复,且该容器中存储的键值对是无序的。 |
unordered_multimap | 和 unordered_map 唯一的区别在于,该容器允许存储多个键相同的键值对。 |
unordered_set | 不再以键值对的形式存储数据,而是直接存储数据元素本身(当然也可以理解为,该容器存储的全部都是键 key 和值 value 相等的键值对,正因为它们相等,因此只存储 value 即可)。另外,该容器存储的元素不能重复,且容器内部存储的元素也是无序的。 |
unordered_multiset | 和 unordered_set 唯一的区别在于,该容器允许存储值相同的元素。 |
程序实例(以 unordered_map 容器为例)
#include <iostream>
#include <string>
#include <unordered_map>
using namespace std;
int main()
{
//创建并初始化一个 unordered_map 容器,其存储的 <string,string> 类型的键值对
std::unordered_map<std::string, std::string> my_uMap{
{"教程1","www.123.com"},
{"教程2","www.234.com"},
{"教程3","www.345.com"} };
//查找指定键对应的值,效率比关联式容器高
string str = my_uMap.at("C语言教程");
cout << "str = " << str << endl;
//使用迭代器遍历哈希容器,效率不如关联式容器
for (auto iter = my_uMap.begin(); iter != my_uMap.end(); ++iter)
{
//pair 类型键值对分为 2 部分
cout << iter->first << " " << iter->second << endl;
}
return 0;
}
/* 程序运行结果:
教程1 www.123.com
教程2 www.234.com
教程3 www.345.com
*/
- 正则表达式
可以认为正则表达式实质上是一个字符串,该字符串描述了一种特定模式的字符串。常用符号的意义如下:
- Lambda匿名函数
所谓匿名函数,简单地理解就是没有名称的函数,又常被称为 lambda 函数或者 lambda 表达式。
(1)定义
lambda 匿名函数很简单,可以套用如下的语法格式:
[外部变量访问方式说明符] (参数) mutable noexcept/throw() -> 返回值类型
{
函数体;
};
其中各部分的含义分别为:
a. [外部变量方位方式说明符]
[ ] 方括号用于向编译器表明当前是一个 lambda 表达式,其不能被省略。在方括号内部,可以注明当前 lambda 函数的函数体中可以使用哪些“外部变量”。
所谓外部变量,指的是和当前 lambda 表达式位于同一作用域内的所有局部变量。
b. (参数)
和普通函数的定义一样,lambda 匿名函数也可以接收外部传递的多个参数。和普通函数不同的是,如果不需要传递参数,可以连同 () 小括号一起省略;
c. mutable
此关键字可以省略,如果使用则之前的 () 小括号将不能省略(参数个数可以为 0)。默认情况下,对于以值传递方式引入的外部变量,不允许在 lambda 表达式内部修改它们的值(可以理解为这部分变量都是 const 常量)。而如果想修改它们,就必须使用 mutable 关键字。
注意:对于以值传递方式引入的外部变量,lambda 表达式修改的是拷贝的那一份,并不会修改真正的外部变量;
d. noexcept/throw()
可以省略,如果使用,在之前的 () 小括号将不能省略(参数个数可以为 0)。默认情况下,lambda 函数的函数体中可以抛出任何类型的异常。而标注 noexcept 关键字,则表示函数体内不会抛出任何异常;使用 throw() 可以指定 lambda 函数内部可以抛出的异常类型。
e. -> 返回值类型
指明 lambda 匿名函数的返回值类型。值得一提的是,如果 lambda 函数体内只有一个 return 语句,或者该函数返回 void,则编译器可以自行推断出返回值类型,此情况下可以直接省略"-> 返回值类型"。
f. 函数体
和普通函数一样,lambda 匿名函数包含的内部代码都放置在函数体中。该函数体内除了可以使用指定传递进来的参数之外,还可以使用指定的外部变量以及全局范围内的所有全局变量。
(2)程序实例
#include <iostream>
#include <algorithm>
using namespace std;
int main()
{
int num[4] = {4, 2, 3, 1};
//对 a 数组中的元素进行排序
sort(num, num+4, [=](int x, int y) -> bool{ return x < y; } );
for(int n : num){
cout << n << " ";
}
return 0;
}
/* 程序运行结果:
1 2 3 4
*/
5.2 说说 C++ 中智能指针和指针的区别是什么?
- 智能指针
如果在程序中使用new从堆(自由存储区)分配内存,等到不需要时,应使用delete将其释放。C++引用了智能指针auto_ptr,以帮助自动完成这个过程。随后的编程体验(尤其是使用STL)表明,需要有更精致的机制。基于程序员的编程体验和BOOST库提供的解决方案,C++11摒弃了auto_ptr,并新增了三种智能指针:unique_ptr、shared_ptr和weak_ptr。所有新增的智能指针都能与STL容器和移动语义协同工作。
- 指针
C 语言规定所有变量在使用前必须先定义,指定其类型,并按此分配内存单元。指针变量不同于整型变量和其他类型的变量,它是专门用来存放地址的,所以必须将它定义为“指针类型”。
智能指针和普通指针的区别
智能指针和普通指针的区别在于智能指针实际上是对普通指针加了一层封装机制,区别是它负责自动释放所指的对象,这样的一层封装机制的目的是为了使得智能指针可以方便的管理一个对象的生命期。