1. 1. 1. 简述下C++语言的特点
C++在C语言基础上引入了面对对象的机制,同时也兼容C语言。
C++有三大特性(1)封装。(2)继承。(3)多态;
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匿名函数、右值引用、智能指针。
2. 2. 2. 说说C语言和C++的区别
C语言是C++的子集,C++可以很好兼容C语言。但是C++又有很多新特性,如引用、智能指针、auto变量等。
C++是面对对象的编程语言;C语言是面对过程的编程语言。
C语言有一些不安全的语言特性,如指针使用的潜在危险、强制转换的不确定性、内存泄露等。而C++对此增加了不少新特性来改善安全性,如const常量、引用、cast转换、智能指针、try—catch等等;
C++可复用性高,C++引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL。C++的STL库相对于C语言的函数库更灵活、更通用。
3. 3. 3. 说说C++中struct和class的区别
相同点:
两者都拥有成员函数、公有和私有部分。
用class完成的工作,同样可以使用struct完成。
不同点:
struct 一般用于描述一个数据结构集合,而 class 是对一个对象数据的封装。
struct 中默认的访问控制权限是 public 的,而 class 中默认的访问控制权限是 private 的。
在继承关系中,struct 默认是公有继承,而 class 是私有继承。
class 关键字可以用于定义模板参数,就像 typename,而 struct 不能用于定义模板参数。
4. 4. 4. 说说include头文件的顺序以及双引号""和尖括号<>的区别
区别:
(1)尖括号<>的头文件是系统文件,双引号""的头文件是自定义文件。
(2)编译器预处理阶段查找头文件的路径不一样。
查找路径:
(1)使用尖括号<>的头文件的查找路径:编译器设置的头文件路径–>系统变量。
(2)使用双引号""的头文件的查找路径:当前头文件目录–>编译器设置的头文件路径–>系统变量。
5. 5. 5. 说说C++结构体和C结构体的区别
C++ 中的 struct 是对 C 中的 struct 进行了扩充。
C语言中的struct不能有成员函数,而C++可以。
使用时的区别:C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字直接使用
6. 6. 6. 导入C函数的关键字是什么,C++编译时和C有什么不同
关键字:在C++中,导入C函数的关键字是extern,表达形式为extern “C”, extern "C"的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern “C"后,会指示编译器这部分代码按C语言的进行编译,而不是C++的。(extern不加上"C”,表示是声明变量)
编译区别:由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。
7. 7. 7. 简述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为后缀。
8. 8. 8. 说说static关键字的作用
(1)定义全局静态变量和局部静态变量:在变量前面加上static关键字。初始化的静态变量会在数据段分配内存,未初始化的静态变量会在BSS段分配内存。直到程序结束,静态变量始终会维持前值。只不过全局静态变量和局部静态变量的作用域不一样,静态变量只能在本源文件中使用;
(2)定义静态函数:在函数返回类型前加上static关键字,函数即被定义为静态函数。
static int a;//静态变量
static void func();//静态函数
(4)static关键字可以用于定义类中的静态成员变量:使用静态数据成员,它既可以被当成全局变量那样去存储,但又被隐藏在类的内部。类中的static静态数据成员拥有一块单独的存储区,而不管创建了多少个该类的对象。所有这些对象的静态数据成员都共享这一块静态存储空间。
(5)在static关键字可以用于定义类中的静态成员函数:与静态成员变量类似,类里面同样可以定义静态成员函数。只需要在函数前加上关键字static即可。如静态成员函数也是类的一部分,而不是对象的一部分。所有这些对象的静态数据成员都共享这一块静态存储空间。
当调用一个对象的非静态成员函数时,系统会把该对象的起始地址赋给成员函数的this指针。而静态成员函数不属于任何一个对象,因此C++规定静态成员函数没有this指针(划重点,面试题常考)。既然它没有指向某一对象,也就无法对一个对象中的非静态成员进行访问。
9. 9. 9. 说说数组和指针的区别
概念:
(1)数组:数组是用于储存多个相同类型数据的集合。 数组名是首元素的地址。
(2)指针:指针相当于一个变量,但是它和不同变量不一样,它存放的是其它变量在内存中的地址。 指针名指向了内存的首地址。
区别:
(1)赋值:同类型指针变量可以相互赋值;数组不行,只能一个一个元素的赋值或拷贝。
(2)存储方式:
数组:数组在内存中是连续存放的,开辟一块连续的内存空间。数组是通过数组下标进行访问的,数组的存储空间,不是在静态区就是在栈上。
指针:指针很灵活,它可以指向任意类型的数据。指针的类型说明了它所指向地址空间的内存。由于指针本身就是一个变量,再加上它所存放的也是变量,所以指针的存储空间不能确定。
(3)求sizeof:
数组所占存储空间的内存大小:sizeof(数组名)。
在32位平台下,无论指针的类型是什么,sizeof(指针名)都是4,在64位平台下,无论指针的类型是什么,sizeof(指针名)都是8。
10. 10. 10. 说说什么是函数指针,如何定义函数指针,有什么使用场景
概念:函数指针就是指向函数的指针变量。每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址。
定义方式:
int func(int a);
int (*f)(int a);
f = &func;
函数指针的应用场景:回调(callback),我们调用别人提供的 API函数,称为Call;如果别人的库里面调用我们的函数,就叫Callback。
11. 11. 11. 静态变量什么时候初始化
对于C语言的全局和静态变量,初始化发生在任何代码执行之前,属于编译期初始化。
而C++标准规定:全局静态变量和局部静态变量当且仅当对象首次用到时才进行构造。
作用域:C++里作用域可分为6种:全局,局部,类,语句,命名空间和文件作用域。
静态全局变量 :全局作用域+文件作用域,所以无法在其他文件中使用。
静态局部变量 :局部作用域,只被初始化一次,直到程序结束。
类静态成员变量:类作用域。
所在空间:都在静态存储区。因为静态变量都在静态存储区,所以下次调用函数的时候还是能取到原来的值。
生命周期:静态全局变量、静态局部变量都在静态存储区,直到程序结束才会回收内存。类静态成员变量在静态存储区,当超出类作用域时回收内存。
12. 12. 12. nullptr调用成员函数可以吗?为什么
能。
class animal {
public:
void sleep() { std::cout << "animal slepp" << std::endl; }
void breathe() { std::cout << "animal breathe" << std::endl; }
};
class fish : public animal {
public:
void breathe() { std::cout << "fish breathe" << std::endl; }
};
int main() {
animal* pAn = nullptr;
pAn->breathe();
fish* pFi = nullptr;
pFi->breathe();
}
原因:因为在编译时对象就绑定了函数地址,和指针空不空没关系。pAn->breathe();编译的时候,函数的地址就和指针pAn绑定了;调用breath(*this), this就等于pAn。由于函数中没有需要解引用this的地方,所以函数运行不会出错,但是若用到this,因为this=nullptr,运行出错。
13. 13. 13. 说说什么是野指针,怎么产生的,如何避免
**概念:野指针就是指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)
产生原因:
(1)释放内存后指针不及时置空(野指针),依然指向了该内存,那么可能出现非法访问的错误。
(2)指针定义时没有初始化
(3)指针操作超越变量作用域
避免办法:
(1)初始化置空
(2)申请内存后,一定要检查有没有给指针分配空间
(3)指针释放后置NULL
(4)使用智能指针
14. 14. 14. 说说静态局部变量,全局变量,局部变量的特点以及使用场景
从作用域考虑:C++里作用域可分为6种:全局,局部,类,语句,命名空间和文件作用域。
全局变量:全局作用域,可以通过extern作用于其他非定义的源文件。
静态全局变量 :全局作用域+文件作用域,所以无法在其他文件中使用。
局部变量:局部作用域,比如函数的参数,函数内的局部变量等等。
静态局部变量 :局部作用域,只被初始化一次,直到程序结束。
从所在空间考虑:除了局部变量在栈上外,其他都在静态存储区。因为静态变量都在静态存储区,所以下次调用函数的时候还是能取到原来的值。
生命周期: 局部变量在栈上,出了作用域就回收内存;而全局变量、静态全局变量、静态局部变量都在静态存储区,直到程序结束才会回收内存。
使用场景:
如果一个变量只需要在一个函数内部使用,可以声明为局部变量。
如果函数中需要使用上一次调用时某个变量的值,但是又不希望其他函数使用它,就可以定义为静态局部变量。
15. 15. 15. 说说内联函数和宏函数的区别
宏定义不是函数,但是使用起来像函数。预处理器用复制宏代码的方式代替函数的调用,省去了函数压栈退栈过程,提高了效率;而内联函数本质上是一个函数,内联函数一般用于函数体的代码比较简单的函数,不能包含复杂的控制语句,while、switch,并且内联函数本身不能直接调用自身。
宏函数是在预编译的时候把所有的宏名用宏体来替换,简单的说就是字符串替换 ;而内联函数则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率。
宏定义是没有类型检查的,无论对还是错都是直接替换,没有返回值;而内联函数在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等。
宏定义不要在最后加分号。
宏定义时要注意书写,否则容易出现歧义,内联函数不会出现歧义。
16. 16. 16. 在main执行之前和之后执行的代码可能是什么
main函数执行之前,主要是初始化系统相关资源:
(1)设置栈指针
(2)初始化static变量和global全局变量,即.data段的内容
(3)将未初始化部分的全局变量赋初值:数值型short,int,long等都为0,bool为false,指针为nullptr,即.bss段的内容
(4)全局对象初始化,在main之前调用构造函数
(5)将main函数的参数argc,argv等传递给main函数,然后才真正运行main函数
(6)__attribute__((constructor))
main函数执行之后:
(1)全局对象的析构函数会在main函数之后执行
(2)可以用atexit注册一个函数,它会在main之后执行
(3)__attribute__((destructor))
17. 17. 17. 结构体内存对照问题
结构体内成员按照声明顺序存储,第一个成员地址和整个结构体地址相同。
未特殊说明时,按结构体中size最大的成员对齐(若有double成员,按8字节对齐。)
c++11以后引入两个关键字 alignas与 alignof。其中alignof可以计算出类型的对齐方式,alignas可以指
定结构体的对齐方式。
alignas在某些情况是不能使用的:
struct A {
uint8_t a;
uint16_t b;
uint8_t c;
};
std::cout << sizeof(A) << std::endl;//6 2 + 2 + 2
std::cout << alignof(A) << std::endl;//2
struct alignas(4) A {
uint8_t a;
uint16_t b;
uint8_t c;
};
std::cout << sizeof(A) << std::endl;//8 4 + 4
std::cout << alignof(A) << std::endl;//4
struct alignas(2) A {
uint8_t a;
uint32_t b;
uint8_t c;
};//若alignas小于自然对齐的最小单位,则忽略,且必须是2的幂
如果要使用单字节对齐的方式,使用alignas是无效的。应该使用#pragma pack(push, 1) 或者使用 attribute((packed))。
#if defined(__GNUC__) || defined(__GNUC)
#define ONEBYTE_ALIGN __attribute__((packed))
#elif defined(_MSC_VER)
#define ONEBYTE_ALIGN
#pragma pack(push, 1)
#endif
struct A {
uint8_t a;
uint32_t b;
uint8_t c;
} ONEBYTE_ALIGN;
#if defined(__GNUC__) || defined(__GNUC)
#undef ONEBYTE_ALIGN
#elif defined(_MSC_VER)
#pragma pack(pop)
#undef ONEBYTE_ALIGN
#endif
int main() {
std::cout << sizeof(A) << std::endl;// 6
std::cout << alignof(A) << std::endl;// 1
}
18. 18. 18. 指针和引用的区别
(1)指针是一个变量,存储的是一个地址,引用跟原来的变量实质上是同一个东西,是原变量的别名。
(2)指针可以有多级,引用只有一级。
(3)指针可以为空,引用不能为NULL且在定义时必须初始化。
(4)引用一旦初始化之后就不可以再改变(变量可以被引用为多次,但引用只能作为一个变量引用);指针变量可以重新指向别的变量。
(5)sizeof指针得到的是本指针的大小,sizeof引用得到的是引用所指向变量的大小。
(6)当把指针作为参数进行传递时,也是将实参的一个拷贝传递给形参,两者指向的地址相同,但不是同一个变量,在函数中改变这个变量的指向不影响实参,而引用却可以。
(7)引用在声明时必须初始化为另一变量,而指针声明和定义可以分开,可以先只声明指针变量而不初始化,等用到时再指向具体变量。
19. 19. 19. 在传递函数参数时,什么时候该使用指针,什么时候该使用引用
(1)需要返回函数内局部变量的内存的时候用指针,用完要记得释放指针,不然会内存泄漏。而返回局部变量的引用是没有意义的
(2)对栈空间大小比较敏感(比如递归)的时候使用引用。使用引用传递不需要创建临时变量,开销要更小
(3)类对象作为参数传递的时候使用引用,这是C++类对象传递的标准方式
20. 20. 20. 堆和栈的区别
栈空间默认是4M,堆区一般是1G-4G。
申请方式不同:栈是系统自动分配的,堆是自己申请和释放的。
申请大小限制不同:栈顶和栈底是之前预设好的,栈是向栈底扩展,大小固定,内存连续,可以通过ulimit -a查看,由ulimit -s修改。而堆向高地址扩展,是不连续的内存区域,大小可以灵活调整。
申请效率不同:栈由系统分配,速度快,不会有碎片。堆由程序员分配,速度慢,且会有碎片。
管理方式不同:堆中资源由程序员控制,容易产生内存泄漏,而栈资源由编译器自动管理,无需手动控制。
内存管理机制不同:对于堆来说,系统有一个记录空闲内存地址的链表,当系统收到程序申请时,遍历该链表,寻找第一个空间大于申请空间的堆结点,删除空闲结点链表中的该结点,并将该结点空间分配给程序(大多数系统会在这块内存空间首地址记录本次分配的大小,这样delete才能正确释放本内存空间,另外系统会将多余的部分重新放在空闲链表中)。对于栈来说,只要栈的剩余空间大于所申请空间,系统为程序提供内存,否则报异常提示栈溢出。
碎片问题:对于堆,频繁的new/delete会造成大量碎片,使程序效率降低。对于栈来说,它有点像先入后出的栈,进出一一对应,不会产生内存碎片。
分配方式:堆都是动态分配,而栈有静态分配和动态分配,静态分配由编译器完成(如局部变量分配),动态分配由alloc函数分配,但栈的动态分配的资源由编译器释放,无需程序员实现。
分配效率:对堆的内存分配由C/C++函数库提供,机制很复杂,所以堆的效率比栈低很多。栈是其系统提供的数据结构,计算机在底层对栈提供支持,分配专门寄存器存放栈地址,栈操作有专门指令。
21. 21. 21. 你觉得堆快一点还是栈快一点
栈快一点。
因为操作系统会在底层对栈提供支持,会分配专门的寄存器存放栈的地址,栈的入栈出栈操作也十分简单,并且有专门的指令执行,所以栈的效率比较高也比较快。而堆的操作是由C/C++函数库提供的,在分配堆内存的时候需要一定的算法寻找合适大小的内存。并且获取堆的内容需要两次访问,第一次访问指针,第二次根据指针保存的地址访问内存,因此堆比较慢。
22. 22. 22. 区别以下指针类型
int* p[10];//存放十个整型指针的数组
int (*p)[10];//一个指针,指向大小为10的整型数组
int* p(int);//一个函数,返回类型为int*,参数类型为int
int (*p)(int);//函数指针,该指针指向的函数具有int类型参数,返回值也是int
23. 23. 23. new/delete 与 malloc/free的异同
相同点:
都可用于内存的申请与释放。
不同点:
(1)new/delete是C++运算符,而malloc和free是C/C++语言标准库函数。
(2)new自动计算要分配的空间大小,malloc需要手工计算。
(3)new是类型安全的,malloc不是。如
int* p = new float(2);//编译错误
int* p = (int*) malloc(2 * sizeof(double));//编译无错误
(4)new调用名为operator new的标准库函数分配足够空间并调用相关对象的构造函数,delete对指针所指对象运行适当的析构函数,
然后通过调用名为operator delete的标准库函数释放该对象所用内存。malloc/free没有相关调用。
(5)malloc/free需要库文件支持,new/delete不需要库文件支持。
(6)new是封装了malloc,直接free不会报错,但这只是释放内存,而不会析构对象。
24. 24. 24. new和delete是如何实现的
(1)new的实现过程是:首先调用名为operator new的标准库函数,分配足够大的内存,以保存指定类型的一个对象;接下来运行该类型的一个构造函数,用指定初始化构造对象;最后返回指向新分配并构造后的的对象的指针。
(2)delete的实现过程:对指针指向的对象运行适当的析构函数;然后通过调用名为operator delete的标准库函数释放该对象所用内存。
25. 25. 25. malloc和new的区别
(1)malloc和free是标准库函数,new和delete是运算符。
(2)malloc仅仅分配内存空间,free仅仅回收空间,不具备调用构造函数和析构函数功能,用malloc分配空间存储类的对象存在风险
(分配的内存需要手动计算,不会调用构造函数,可能分配失败);new和delete除了分配回收功能外,还会调用构造函数和析构函数。
(3)malloc和free返回的是void类型指针(必须进行类型转换),new和delete返回的是具体类型指针。
(4)malloc失败返回空,new失败抛异常。
26. 26. 26. 既然有了malloc/free,C++中为什么还需要new/delete呢,直接用malloc/free不好吗
(1)malloc/free和new/delete都是用来申请内存和回收内存的。
(2)在创建非基本数据类型的对象的时候,对象创建的时候还需要执行构造函数,销毁的时候要执行析构函数。而malloc/free是库函数,是已经编译的代码,所以不能把构造函数和析构函数的功能强加给malloc/free,而new/delete会调用对象的构造函数和析构函数,
所以new/delete是必不可少的。
27. 27. 27. 被free回收的内存是立即返还给操作系统吗
不是的,被free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片。
28. 28. 28. 宏定义和typedef区别
(1)宏主要用于定义常量及书写复杂的内容;typedef主要用于定义类型别名。
(2)宏替换发生在编译阶段之前,属于文本插入替换;typedef是编译的一部分。
(3)宏不检查类型;typedef会检查数据类型。
(4)宏不是语句,不在在最后加分号;typedef是语句,要加分号标识结束。
(5)注意对指针的操作,typedef char * p_char和#define p_char char *区别巨大。
29. 29. 29. 变量定义和声明的区别
(1)声明仅仅是把变量的声明的位置及类型提供给编译器,并不分配内存空间,定义要在定义的地方分配存储空间。
(2)相同变量可以在多处声明(外部变量extern),但只能在一处定义。
30. 30. 30. strlen和sizeof区别
(1)sizeof是运算符,并不是函数,结果在编译时得到而非运行中获得;strlen是字符处理的库函数。
(2)sizeof参数可以是任何数据的类型或者数据(sizeof参数不退化);strlen的参数只能是字符指针且结尾是’\0’的字符串。
(3)因为sizeof值在编译时确定,所以不能用来得到动态分配(运行时分配)存储空间的大小。
31. 31. 31. 常量指针和指针常量的区别
指针常量是一个普通指针,该指针指向一个只读变量,也可以指向一个普通变量,可以多次改变指针的指向。
常量指针是一个不能改变指向的指针,该指针是个常量,必须初始化,一旦初始化完成,它的值也就不能再改变了。
int* const p;//常量指针
const int* p;//指针常量
int const* p;//指针常量
32. 32. 32. a和&a有什么区别
假设数组int a[10]; int (*p)[10] = &a;
其中:
(1)a是数组名,是数组首元素地址,+1表示地址值加上一个int类型的大小,如果a的值是0x00000001,加1操作后就变成了0x00000005。*(a + 1) = a[1];
(2)&a是数组的指针,其类型为int(*)[10],其加1时,系统会认为是数组首地址加上整个数组的偏移(10个int变量),值为数组a尾元素后一个元素的地址。
(3)若(int _)p,此时输出_p时,其值为a[0],因为被转为int*类型,解引用时按照int类型大小来读取。
33. 33. 33. C++和C语言的区别
(1)C语言是C++的子集,C++可以很好兼容C语言。但是C++又有很多新特性,如引用、智能指针、auto变量等。
(2)C++是面对对象的编程语言;C语言是面对过程的编程语言。
(3)C语言有一些不安全的语言特性,如指针使用的潜在危险、强制转换的不确定性、内存泄露等。而C++对此增加了不少新特性来改善安全性,如const常量、引用、cast转换、智能指针、try—catch等等;
(4)C++可复用性高,C++引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL。C++的STL库相对于C语言的函数库更灵活、更通用。
(5)C++中new和delete是对内存分配的运算符,取代了C中的malloc和free。
(6)标准C++中的字符串类取代了标准C函数库头文件中的字符数组处理函数。
(7)C++中用来做控制态输入输出的iostream库替代了C语言中的stdio函数库
(8)C++中的try/catch/throw异常处理机制取代了标准C中的setjmp()和longjmp()函数。
(9)在C++中,允许有相同的函数名,不过它们的参数类型不能完全相同,这样这些函数就可以相互区别开来。而这在C语言中是不允许
的。也就是C++可以重载,C语言不允许。
(10)C++语言中,允许变量定义语句在程序中的任何地方,只要在是使用它之前就可以;而C语言中,必须要在函数开头部分。而且
C++允许重复定义变量,C语言也是做不到这一点的。
(11)在C++中,除了值和指针之外,新增了引用。引用型变量是其他变量的一个别名,我们可以认为他们只是名字不相同,其他都是相同的。
(12)C++相对与C增加了一些关键字,如:bool、using、dynamic_cast、namespace等等。
34. 34. 34. define宏定义和const的区别
编译阶段:
(1)define是在预处理阶段起作用,而const是在编译、运行的时候起作用。
(2)define只做替换,不做类型检查和计算,也不求解,容易产生错误,一般最好加上一个大括号包含住全部的内容,要不然很容易出错。const常量有数据类型,编译器可以对其进行类型安全检查。
(3)define只是将宏名称进行替换,在内存中会产生多份相同的备份。const在程序运行中只有一份备份,且可以执行常量折叠,能将复杂的的表达式计算出结果放入常量表。
(4)宏定义的数据没有分配内存空间,只是插入替换掉;const定义的变量只是值不能改变,但要分配内存空间。
35. 35. 35. const和static的作用
static的作用:
不考虑类的情况:
(1)隐藏。所有不加static的全局变量和函数具有全局可见性,可以在其他文件中使用,加了之后只能在该文件所在的编译模块中使用。
(2)默认初始化为0,包括未初始化的全局静态变量与局部静态变量,都存在全局未初始化区。
(3)静态变量在函数内定义,始终存在,且只进行一次初始化,具有记忆性,其作用范围与局部变量相同,函数退出后仍然存在,但不
能使用。
考虑类的情况:
(1)static成员变量:只与类关联,不与类的对象关联。定义时要分配空间,不能在类声明中初始化,必须在类定义体外部初始化,初始化时不需要标示为static;可以被非static成员函数任意访问。
(2)static成员函数:不具有this指针,无法访问类对象的非static成员变量和非static成员函数;不能被声明为const、虚函数和volatile;可以被非static成员函数任意访问。
(3)static成员函数不能被virtual修饰,static成员不属于任何对象或实例,所以加上virtual没有任何实际意义;静态成员函数没有this指针,虚函数的实现是为每一个对象分配一个vptr指针,而vptr是通过this指针调用的,所以不能为virtual;虚函数的调用关系:
this->vptr->ctable->virtual function
const的作用:
不考虑类的情况:
(1)const常量在定义时必须初始化,之后无法更改。
(2)const形参可以接收const和非const类型的实参。
考虑类的情况:
(1)const成员变量:不能在类定义外部初始化,只能通过构造函数初始化列表进行初始化,并且必须有构造函数;不同类对其const数据成员的值可以不同,所以不能在类中声明时初始化。
(2)const成员函数:const对象不可以调用非const成员函数;非const对象都可以调用;不可以改变非mutable(用该关键字声明的变量可以在const成员函数中被修改)数据的值。
36. 36. 36. C++的顶层const和底层const
顶层const:指的是const修饰的变量本身是一个常量,无法修改,指的是指针。
底层const:指的是const修饰的变量所指向的对象是一个常量,指的是所指变量。
区别作用:
(1)执行对象拷贝时有限制,常量的底层const不能赋值给非常量的底层const。
(2)使用命名的强制类型转换函数const_cast时,只能改变运算对象的底层const。
37. 37. 37. 数组名和指针区别
(1)二者均可通过增减偏移量来访问数组中的元素。
(2)数组名不是真正意义上的指针,可以理解为常指针,所以数组名没有自增、自减等操作。
(3)当数组名当做形参传递给调用函数后,就失去了原有特性,退化成一般指针,多了自增、自减操作,但sizeof运算符不能再得到原数组的大小了。
38. 38. 38. final和override关键字
override:
当在父类函数使用了虚函数时候,你可能需要在某个子类对这个虚函数进行重写:
class A
{
virtual void foo();
}
class B : public A
{
void foo(); //OK
virtual void foo(); // OK
void foo() override; //OK
}
如果不是用override,当你把函数名字写错了,编译器不会报错,它并不知道你的目的是重写虚函数,而是把它当成了新的函数,如果这个函数很重要,就有可能对整个程序不利。所以override的多用就出来了,它指定了子类的这个虚函数是重写父类的,如果名字不小心打错,那么编译器不会通过。
final:
当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错。
39. 39. 39. 拷贝初始化和直接初始化
(1)当用于类类型对象时,初始化的拷贝形式和直接形式有所不同:直接初始化直接调用与实参匹配的构造函数,拷贝初始化总是调用拷贝构造函数。拷贝初始化首先使用指定构造函数创建一个临时对象,然后用拷贝构造函数将那个临时对象拷贝到正在创建的对象。
(2)为了提高效率,允许编译器跳过创建临时对象这一步,直接调用构造函数构造要创建的对象,这样就完全等价于直接初始化了,但是需要辨别两种情况。当拷贝构造函数为private时,进行拷贝初始化会报错。使用explicit时,如果构造函数存在隐式转换,编译时会报错。
40. 40. 40. 初始化和赋值的区别
(1)对于简单类型来说,初始化和赋值没有什么区别。
(2)对于类和复杂数据类型来说,两者的区别就大了:
class A{
public:
int num1;
int num2;
public:
A(int a=0, int b=0):num1(a),num2(b){};
A(const A& a){};
//重载 = 号操作符函数
A& operator=(const A& a){
num1 = a.num1 + 1;
num2 = a.num2 + 1;
return *this;
};
};
int main(){
A a(1,1);
A a1 = a; //拷贝初始化操作,调用拷贝构造函数
A b;
b = a;//赋值操作,对象a中,num1 = 1,num2 = 1;对象b中,num1 = 2,num2 = 2
return 0;
}
41. 41. 41. extern"C"的正确做法
为了能够正确的在C++代码中调用C语言的代码:在程序中加上extern "C"后,相当于告诉编译器这部分代码是C语言写的,因此要按照C语言进行编译,而不是C++;
哪些情况下使用extern"C":
(1)C++代码中调用C语言代码。
(2)在C++的头文件中使用。
(3)在多人协同开发的时候,可能有人擅长C语言,而有人擅长C++。
42. 42. 42. 野指针和悬空指针
都是指向无效内存区域(无效指“不安全不可控”)的指针,访问行为将会导致未定义行为。
野指针:指没有被初始化的指针。因此为了防止出错,对于指针初始化时都是赋值nullptr,这样在使用时编译器就会抛出异常,产生非法内存访问。
悬空指针:指针最初指向的内存已经被释放了的一种指针。
避免野指针比较简单,但是避免悬空指针比较麻烦。C++引入了智能指针,C++智能指针的本质就是避免悬空指针的产生。
产生原因及解决方法:
野指针:指针变量未及时初始化,定义指针变量及时初始化,或者置空。
悬空指针:指针free或delete之后没有及时置空,释放操作后立即置空。
43. 43. 43. C和C++的类型安全
什么是类型安全?
类型安全很大程度上可以等价于内存安全,类型安全的代码不会试图访问自己没被授权的内存区域。“类型安全”常被用来形容编程语言,
其根据在于该门编程语言是否提供保障类型安全的机制;有的时候也用“类型安全”形容某个程序,判别的标准在于该程序是否隐含类型错误。类型安全的编程语言与类型安全的程序之间,没有必然联系。好的程序员可以使用类型不那么安全的语言写出类型相当安全的程序,
相反的,差一点儿的程序员可能使用类型相当安全的语言写出类型不太安全的程序。绝对类型安全的编程语言暂时还没有。
(1)C的类型安全
C只在局部上下文中表现出类型安全,比如试图从一种结构体的指针转换成另一种结构体的指针时,编译器将会报告错误,除非使用显式
类型转换。C相当多的操作是不安全的,例如printf和malloc
(2)C++的类型安全
C++使用得当,将远比C更具类型安全性。
比如:
操作符new返回的指针类型严格与对象匹配,而不是void*。
C中很多以void*为参数的函数可以改写为C++模板函数,而模板是支持类型检查的。
引入const关键字代替#define定义常量,它是有类型、有作用域的,而#define只是简单的文本替换。
一些#define宏可被改写为inline函数,结合函数的重载,可在类型安全的前提下支持多种类型,当然改写为模板也能保证类型安全。
C++提供了dynamic_cast关键字,使得转换过程更加安全,因为dynamic_cast比static_cast涉及更多具体的类型检查。
要想保证程序的类型安全性,应尽量避免使用空类型void*,尽量不对两种类型指针做强制转换。
44. 44. 44. C++中的重载、重写(覆盖)和隐藏的区别
(1)重载(overload)
重载是指在同一范围定义中的同名成员函数才存在重载关系。主要特点是函数名相同,参数类型和数目有所不同,不能出现参数个数和类型均相同,仅仅依靠返回值不同来区分的函数。重载和函数成员是否是虚函数无关。
(2)重写(override)
重写指的是在派生类中覆盖基类中的同名函数,重写就是重写函数体,要求基类函数必须是虚函数且与基类的虚函数有相同的参数个数,与基类的虚函数有相同的参数类型,与基类的虚函数有相同的返回值类型。
(3)隐藏
隐藏指的是某些情况下,派生类中的函数屏蔽了基类中的同名函数,包括以下情况:
两个函数参数相同,但是基类函数不是虚函数。和重写的区别在于基类函数是否是虚函数。
两个函数参数不同,无论基类函数是不是虚函数,都会被隐藏。和重载的区别在于两个函数不在同一个类中。
重载和重写的区别:
(1)重写是父类和子类之间的垂直关系,重载是不同函数之间的水平关系。
(2)重写要求参数列表相同,重载则要求参数列表不同,返回值不要求。
(3)重写关系中,调用方法根据对象类型决定,.重载根据调用时实参表与形参表的对应关系来选择函数体。
45. 45. 45. C++有哪几种的构造函数
(1)默认构造函数
(2)初始化构造函数(有参数)
(3)拷贝构造函数
(4)移动构造函数(move和右值引用)
(5)委托构造函数
(6)转换构造函数
#include <iostream>
using namespace std;
class Student {
public:
Student() {} //默认构造函数
Student(int a, int b, std::string name) {} //初始化构造函数,有参数和参数列表
Student(const Student&);//拷贝构造函数
Student(Student&&);//移动构造函数
Student(double);//转换构造函数,形参是其他类型变量,且只有一个形参
Student(std::string _s) : Student(0, 0, _s) {}//委托构造函数
private:
int year;
int age;
std::string name;
}
默认构造函数和初始化构造函数在定义类的对象,完成对象的初始化工作。
拷贝构造函数用于拷贝本类的对象。
转换构造函数用于将其他类型的变量,隐式转换为本类对象。
委托构造函数可以使用当前类的其他构造函数来帮助当前构造函数初始化,也就是说可以将当前构造函数的部分或全部职责交给本类的另一个构造函数。
委托构造函数和普通构造函数的相同点和不同点:
相同点:两者都有一个成员初始值列表与一个函数体。
不同点:委托构造函数的成员初始值列表只有一个唯一的参数,就是构造函数(不能是当前的构造函数)。
委托函数的成员初始化列表中只能有一个构造函数,且不能含有其他成员变量的初始化,而且要避免循环调用。
46. 46. 46. 浅拷贝和深拷贝的区别
浅拷贝:浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。
深拷贝:深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值,两个对象之间没有任何关系。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。·
47. 47. 47. 内联函数适用场景
使用宏定义的地方使用inline函数。
作为类成员接口函数来读写类的私有成员或者保护成员,会提高效率。
48. 48. 48. 访问权限public,protected和private和继承权限public,protected和private的区别
public的变量和函数在类的内部和外部都可以访问。
protected的变量和函数只能在类的内部和其派生类中访问。
private修饰的元素只能在类内访问。
(1)访问权限
派生类可以继承基类中除了构造/析构、赋值运算符重载函数之外的成员,但是这些成员的访问属性在派生过程中也是可以调整的,三种
派生方式的访问权限如下表所示:注意外部访问并不是真正的外部访问,而是在通过派生类的对象对基类成员的访问。
派生类对基类成员的访问形象有如下两种:
内部访问:由派生类中新增的成员函数对从基类继承来的成员的访问。
外部访问:在派生类外部,通过派生类的对象对从基类继承来的成员的访问。
(2)继承权限
public继承
公有继承的特点是基类的公有成员和保护成员作为派生类的成员时,都保持原有的状态,而基类的私有成员任然是私有的,不能被这个派生类的子类所访问。
protected继承
保护继承的特点是基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元函数访问,基类的私有成员仍然是私有的,访问规则如下表
private继承
私有继承的特点是基类的所有公有成员和保护成员都成为派生类的私有成员,并不被它的派生类的子类所访问,基类的成员只能由自己派生类访问,无法再往下继承,访问规则如下表
49. 49. 49. 如何用代码判断大小端存储
大端存储:高的有效位字节存储在低的存储器地址
小端存储:低的有效位字节存储在低的存储器地址
在socket编程中,往往需要将操作系统所用的小端存储的IP地址转换为大端存储,这样才能进行网络传输。
代码判断:
(1)强制类型转换
int a = 0x1234;
char c = (char) a;
//由于int和char的长度不同,借助int型转换为char型,只会留下低地址的部分
if(c == 0x12) std::cout << "big" << std::endl;
else std::cout << "small" << std::endl;
(2)使用union联合体
union endian {
int a;
char ch;
};
endian value;
value.a = 0x1234;
if(value.ch == 0x12) std::cout << "big" << std::endl;
else std::cout << "small" << std::endl;
50. 50. 50. volatile、mutable和explicit关键字的用法
(1)volatile
volatile 关键字是一种类型修饰符,用它声明的类型变量表示可以被某些编译器未知的因素更改,比如:操作系统、硬件或者其它线程等。遇到这个关键字声明的变量,编译器对访问该变量的代码就不再进行优化,从而可以提供对特殊地址的稳定访问。
当要求使用 volatile 声明的变量的值的时候,系统总是重新从它所在的内存读取数据,即使它前面的指令刚刚从该处读取过数据。
volatile定义变量的值是易变的,每次用到这个变量的值的时候都要去重新读取这个变量的值,而不是读寄存器内的备份。多线程中被几个任务共享的变量需要定义为volatile类型。
volatile 指针和 const 修饰词类似,const 有常量指针和指针常量的说法,volatile 也有相应的概念。
注意:
(1)可以把一个非volatile int赋给volatile int,但是不能把非volatile对象赋给一个volatile对象。
(2)除了基本类型外,对用户定义类型也可以用volatile类型进行修饰。
多线程下的volatile
有些变量是用volatile关键字声明的。当两个线程都要用到某一个变量且该变量的值会被改变时,应该用volatile声明,该关键字的作用是
防止优化编译器把变量从内存装入CPU寄存器中。如果变量被装入寄存器,那么两个线程有可能一个使用内存中的变量,一个使用寄存器中的变量,这会造成程序的错误执行。volatile的意思是让编译器每次操作该变量时一定要从内存中真正取出,而不是使用已经存在寄存器中的值。
(2)mutable
mutable的中文意思是“可变的,易变的”,在C++中,mutable是为了突破const的限制而设置的。被mutable修饰的变量,将永远处于可变的状态,即使在一个const函数中。我们知道,如果类的成员函数不会改变对象的状态,那么这个成员函数一般会声明成const的。但是,有些时候,我们需要在const函数里面修改一些跟类状态无关的数据成员,那么这个函数就应该被mutable来修饰,并且放在函数后面关键字位置。
(3)explicit
explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换,只能以显示的方式进行类型转换,注意
以下几点:
(1)explicit 关键字只能用于类内部的构造函数声明上。
(2)explicit 关键字作用于单个参数的构造函数。
(3)被explicit修饰的构造函数的类,不能发生相应的隐式类型转换。
51. 51. 51. 什么情况下会调用拷贝构造函数
(1)用类的一个实例化对象去初始化另一个对象的时候。
(2)函数的参数是类的对象时(非引用传递)。
(3)函数的返回值是函数体内局部对象的类的对象时 ,此时虽然发生(Named return Value优化)NRV优化,但是由于返回方式是值传递,所以会在返回值的地方调用拷贝构造函数。
linux g++的环境下,第三种情况不会调用拷贝构造函数。
在c++编译器发生NRV优化,如果是引用返回的形式则不会调用拷贝构造函数,如果是值传递的方式依然会发生拷贝构造函数。
52. 52. 52. C++中有几种类型的new
在C++中,new有三种典型的使用方法:plain new,nothrow new和placement new
(1) plain new
普通的new,就是我们常用的new,在C++中定义如下:
void* operator new(std::size_t) throw(std::bad_alloc);
void operator delete(void*) throw()
plain new在空间分配失败的情况下,抛出异常std::bad_alloc而不是返回NULL,因此通过判断返回值是否为NULL是徒劳的。
(2)nothrow new
nothrow new在空间分配失败的情况下不抛出异常,而是返回NULL,定义如下:
void* operator new(std::size_t, const std::nothrow_t&) throw();
void operator delete(void*) throw();
用法:
char* p = new(nothrow) char[10e11];
if(p == NULL) std::cout << "YES" << std::endl;
delete p;
(3)placement new
这种new允许在一块已经分配成功的内存上重新构造对象或对象数组。placement new不用担心内存分配的失败,因为它根本不分配内存,它做的唯一一件事就是调用对象的构造函数。定义如下:
void* operator new(std::size_t, void*);
void operator delete(void*, void*);
使用placement new需要注意两点:
(1)palcement new的主要用途就是反复使用一块较大的动态分配的内存来构造不同类型的对象或者他们的数组。
(2)placement new构造起来的对象数组,要显式的调用他们的析构函数来销毁(析构函数并不释放对象的内存),千万不要使用delete,这是因为placement new构造起来的对象或数组大小并不一定等于原来分配的内存大小,使用delete会造成内存泄漏或者之后释放内存时出现运行时错误。
用法:
char* p = new(nothrow) char[sizeof ADT + 1];
if(p == NULL) std::cout << "alloc failed" << std::endl;
ADT* q = new(p) ADT;//placement new
q->ADT::~ADT();//显式调用析构函数
delete[] p;//最后不再使用时释放内存
return 0;
53. 53. 53. C++异常处理的方法
在程序执行过程中,由于程序员的疏忽或是系统资源紧张等因素都有可能导致异常,任何程序都无法保证绝对的稳定,常见的异常有:下标越界,除数为0,动态分配空间时空间不足。如果不及时对异常进行处理,程序多数情况下会崩溃。
(1)try、throw、catch关键字
C++中的异常处理机制主要用try、throw和catch三个关键字,将可能抛出异常的程序段放入try块中,使用catch来捕捉异常,使用throw来抛出异常。程序的执行流程是先执行try包裹的语句块,如果执行过程中没有异常发生,则不会进入任何catch包裹的语句块,如果发生异常,则使用throw进行异常抛出,再由catch进行捕获,throw可以抛出各种数据类型的信息,可以是数字,也可以自定义异常class。catch根据throw抛出的数据类型进行精确捕获(不会出现类型转换),如果匹配不到就直接报错,可以使用catch(…)的方式捕获任何异常(不推荐)。如果catch了异常,当前函数不进行处理,或者已经处理了想通知上一层的调用者,可以在catch里面再throw异常。
(2)函数的异常声明列表
有时候,程序员在定义函数时已经知道函数可能发生的异常,可以在函数声明时,指出所能抛出异常的列表,写法如下:
int fun() throw(int, double, A, B, C) {...};
表示该函数可能会抛出int,double,A,B,C类型的异常,如果列表为空,则表示可以抛出任何异常,省略throw表示可以抛出任何异常。
(3)C++标准异常类exception
bad_typeid:使用typeid运算符,如果其操作数是一个多态类的指针,而该指针的值为 NULL,则会拋出此异常。
bad_cast:在用 dynamic_cast 进行从多态基类对象(或引用)到派生类的引用的强制类型转换时,如果转换是不安全的,则会拋出此异常。
bad_alloc:在用 new 运算符进行动态内存分配时,如果没有足够的内存,则会引发此异常。
out_of_range:用 vector 或 string的at 成员函数根据下标访问元素时,如果下标越界,则会拋出此异常。
54. 54. 54. 指针和const的用法
(1)const修饰指针时,用于const的位置不同,它的修饰对象会有所不同。
(2)int* const p2中是常量指针,该指针不能改变指向,为顶层const。
(3)int const* p2中是指针常量,表示该指针指向一个常量,该指针的指向可以多次改变,为底层const。
55. 55. 55. 形参与实参的区别
- 形参变量只有在被调用时才分配内存单元,在调用结束时, 即刻释放所分配的内存单元。形参只有在函数内部有效。 函数调用结束返回主调函数后则不能再使用该形参变量。
- 实参可以是常量、变量、表达式、函数等, 无论实参是何种类型的量,在进行函数调用时,它们都必须具有确定的值, 以便把这些值传送给形参。 因此应预先用赋值,输入等办法使实参获得确定值。
- 实参和形参在数量上,类型上,顺序上应严格一致, 否则会发生“类型不匹配”的错误。
- 函数调用中发生的数据传送是单向的。 即只能把实参的值传送给形参,而不能把形参的值反向地传送给实参。 因此在函数调用过程中,形参的值发生改变,而实参中的值不会变化。
- 当形参和实参不是指针类型时,在该函数运行时,形参和实参是不同的变量,他们在内存中位于不同的位置,形参将实参的内容复制一份,在该函数运行结束的时候形参被释放,而实参内容不会改变。
56. 56. 56. 值传递、指针传递、引用传递的区别和效率
- 值传递:有一个形参向函数所属的栈拷贝数据的过程,如果值传递的对象是类对象或是大的结构体对象,将耗费一定的时间和空间。(传值)
- 指针传递:同样有一个形参向函数所属的栈拷贝数据的过程,但拷贝的数据是一个固定为4字节的地址。(传值,传递的是地址值)
- 引用传递:同样有上述的数据拷贝过程,但其是针对地址的,相当于为该数据所在的地址起了一个别名。(传地址)
- 效率上讲,指针传递和引用传递比值传递效率高。一般主张使用引用传递,代码逻辑上更加紧凑、清晰。
57. 57. 57. 静态变量什么时候初始化
-
初始化只有一次,但是可以多次赋值,在主程序之前,编译器已经为其分配好了内存。
-
静态局部变量和全局变量一样,数据都存放在全局区域,所以在主程序之前,编译器已经为其分配好了内存,但在C和C++中静态局部变量的初始化节点又有点不太一样。在C中,初始化发生在代码执行之前,编译阶段分配好内存之后,就会进行初始化,所以我们看到在C语言中无法使用变量对静态局部变量进行初始化,在程序运行结束,变量所处的全局内存会被全部回收。
-
而在C++中,初始化时在执行相关代码时才会进行初始化,主要是由于C++引入对象后,要进行初始化必须执行相应构造函数和析构函数,在构造函数或析构函数中经常会需要进行某些程序中需要进行的特定操作,并非简单地分配内存。所以C++标准定为全局或静态对象是有首次用到时才会进行构造,并通过atexit()来管理。在程序结束,按照构造顺序反方向进行逐个析构。所以在C++中是可以使用变量对静态局部变量进行初始化的。
58. 58. 58. const关键字的作用有哪些
-
阻止一个变量被改变,可以使用const关键字。在定义该const变量时,通常需要对它进行初始化,因为以后就没有机会再去改变它了;
-
对指针来说,可以指定指针本身为const,也可以指定指针所指的数据为const,或二者同时指定为const;
-
在一个函数声明中,const可以修饰形参,表明它是一个输入参数,在函数内部不能改变其值;
-
对于类的成员函数,若指定其为const类型,则表明其是一个常函数,不能修改类的成员变量,类的常对象只能访问类的常成员函数;
-
对于类的成员函数,有时候必须指定其返回值为const类型,以使得其返回值不为“左值”。
-
const成员函数可以访问非const对象的非const数据成员、const数据成员,也可以访问const对象内的所有数据成员;
-
非const成员函数可以访问非const对象的非const数据成员、const数据成员,但不可以访问const对象的任意数据成员;
-
const类型变量可以通过类型转换符const_cast将const类型转换为非const类型;
-
const类型变量必须定义的时候进行初始化,因此也导致如果类的成员变量有const类型的变量,那么该变量必须在类的初始化列表中进行初始化;
-
对于函数值传递的情况,因为参数传递是通过复制实参创建一个临时变量传递进函数的,函数内只能改变临时变量,但无法改变实参。则这个时候无论加不加const对实参不会产生任何影响。但是在引用或指针传递函数调用中,因为传进去的是一个引用或指针,这样函数内部可以改变引用或指针所指向的变量,这时const 才是实实在在地保护了实参所指向的变量。因为在编译阶段编译器对调用函数的选择是根据实参进行的,所以,只有引用传递和指针传递可以用是否加const来重载。一个拥有顶层const的形参无法和另一个没有顶层const的形参区分开来。
59. 59. 59. 类的继承
- 类与类之间的关系
has-A包含关系,用以描述一个类由多个部件类构成,实现has-A关系用类的成员属性表示,即一个类的成员属性是另一个已经定义好的类;
use-A,一个类使用另一个类,通过类之间的成员函数相互联系,定义友元或者通过传递参数的方式来实现;
is-A,继承关系,关系具有传递性;
-
继承的相关概念
所谓的继承就是一个类继承了另一个类的属性和方法,这个新的类包含了上一个类的属性和方法,被称为子类或者派生类,被继承的类称为父类或者基类; -
继承的特点
子类拥有父类的public和protected属性和方法,子类可以拥有父类没有的属性和方法,子类对象可以当做父类对象使用;
-
继承中的访问控制
public、protected、private -
继承中的构造和析构函数
建立对象时,先调用基类的构造函数,再调用子类的构造函数。析构时相反。
-
继承中的兼容性原则
类的兼容性是指在需要基类对象的任何地方,都可以使用公有派生类的对象来替代,通过公有继承,派生类得到了基类中出构造函数、析构函数之外的所有成员。这样,公有派生类实际上就具有了基类的所有功能,凡是基类可以解决的问题,公有派生类都可以解决。继承类有以下五个原则:
1):子类对象可以当做父类对象使用
2):子类对象可以直接赋值给父类对象
3):子类对象可以直接初始化父类对象
4):父类指针可以直接指向子类对象
5):父类引用可以直接引用子类对象
60. 60. 60. 深拷贝和浅拷贝可以描述一下吗
浅拷贝:浅拷贝只是拷贝一个指针,并没有新开辟一个地址,拷贝的指针和原来的指针指向同一块地址,如果原来的指针所指向的资源释放了,那么再释放浅拷贝的指针的资源就会出现错误。
深拷贝:深拷贝不仅拷贝值,还开辟出一块新的空间用来存放新的值,即使原先的对象被析构掉,释放内存了也不会影响到深拷贝得到的值,两个对象之间没有任何关系。在自己实现拷贝赋值的时候,如果有指针变量的话是需要自己实现深拷贝的。
61. 61. 61. new和malloc的区别
1、 new/delete是C++关键字,需要编译器支持。malloc/free是库函数,需要头文件支持;
2、 使用new操作符申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。而malloc则需要显式地指出所需内存的尺寸。
3、 new操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故new是符合类型安全性的操作符。而malloc内存分配成功则是返回void * ,需要通过强制类型转换将void*
指针转换成我们需要的类型。
4、 new内存分配失败时,会抛出bac_alloc异常。malloc分配内存失败时返回NULL。
5、 new会先调用operator new函数,申请足够的内存(通常底层使用malloc实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete先调用析构函数,然后调用operator delete函数释放内存(通常底层使用free实现)。malloc/free是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。
62. 62. 62. delete p、delete[] p和allocator都有什么作用
1、 动态数组管理new一个数组时,[]中必须是一个整数,但是不一定是常量整数,普通数组必须是一个常量整数;
2、 new动态数组返回的并不是数组类型,而是一个元素类型的指针;
3、 delete[]时,数组中的元素按逆序的顺序进行销毁;
4、 new在内存分配上面有一些局限性,new的机制是将内存分配和对象构造组合在一起,同样的,delete也是将对象析构和内存释放组合在一起的。allocator将这两部分分开进行,allocator申请一部分内存,不进行初始化对象,只有当需要的时候才进行初始化操作。
63. 63. 63. new和delete的实现原理, delete是如何知道释放内存的大小的
1、 new简单类型直接调用operator new分配内存;而对于复杂结构,先调用operator new分配内存,然后在分配的内存上调用构造函数;对于简单类型,new[]计算好大小后调用operator new;对于复杂数据结构,new[]先调用operator new[]分配内存,然后在p的前四个字节写入数组大小n,然后调用n次构造函数,针对复杂类型,new[]会额外存储数组大小;
① new表达式调用一个名为operator new(operator new[])函数,分配一块足够大的、原始的、未命名的内存空间;
② 编译器运行相应的构造函数以构造这些对象,并为其传入初始值;
③ 对象被分配了空间并构造完成,返回一个指向该对象的指针。
2、 delete简单数据类型默认只是调用free函数;复杂数据类型先调用析构函数再调用ope rator delete;针对简单类型,delete和delete[]等同。假设指针p指向new[]分配的内存。因为要4字节存储数组大小,实际分配的内存地址为[p-4],系统记录的也是这个地址。delete[]实际释放的就是p-4指向的内存。而delete会直接释放p指向的内存,这个内存根本没有被系统记录,所以会崩溃。
3、 需要在 new [] 一个对象数组时,需要保存数组的维度,C++ 的做法是在分配数组空间时多分配了 4个字节的大小,专门保存数组的大小,在 delete [] 时就可以取出这个保存的数,就知道了需要调用析构函数多少次了。
64. 64. 64. malloc申请的存储空间能用delete释放吗
不能,malloc /free主要为了兼容C,new和delete 完全可以取代malloc /free的。
malloc /free的操作对象都是必须明确大小的,而且不能用在动态类上。
new 和delete会自动进行类型检查和大小,malloc/free不能执行构造函数与析构函数,所以动态对象它是不行的。
当然从理论上说使用malloc申请的内存是可以通过delete释放的。不过一般不这样写的。而且也不能保证每个C++的运行时都能正常。
65. 65. 65. malloc与free的实现原理
1、 在标准C库中,提供了malloc/free函数分配和释放内存,这两个函数底层是由brk、mmap、munmap这些系统调用实现的。
2、 brk是将数据段(.data)的最高地址指针_edata往高地址推,mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。这两种方式分配的都是虚拟内存,没有分配物理内存。在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。
3、 malloc分配小于128k的内存,使用brk分配内存,将_edata往高地址推;malloc大于128k的内存,使用mmap分配内存,在堆和栈之间找一块空闲内存分配;brk分配的内存需要等到高地址内存释放以后才能释放 , 而mmap分配的内存可以单独释放 。当最高地址空间的空闲内存超过128K(可由M_TRIM_THRESHOLD选项调节)时,执行内存紧缩操作(trim)。
4、 malloc是从堆里面申请内存,也就是说函数返回的指针是指向堆里面的一块内存。操作系统中有一个记录空闲内存地址的链表。当操作系统收到程序的申请时,就会遍历该链表,然后就寻找第一个空间大于所申请空间的堆结点,然后就将该结点从空闲结点链表中删除,并将该结点的空间分配给程序。
66. 66. 66. malloc、realloc、calloc的区别
-
malloc函数
void* malloc(unsigned int nums_size); int* p = malloc(20 * sizeof(int))
-
calloc函数
void* calloc(size_t n, size_t size); int* p = calloc(20, sizeof(int));
省去了人为空间计算,malloc申请的空间的值是随机初始化的,calloc申请的空间是初始化为0的
-
realloc函数
void realloc(void* p, size_t new_size);
给动态分配的空间再次分配空间,常用于扩充容量。
如果当前内存段后面有需要的内存空间,则直接扩展这段内存空间,返回原指针。
如果当前内存段后面的空闲字节不够,那么就使用堆中的第一个能够满足这一要求的内存块,将目前的数据复制到新的位置,并将原来的数据块释放掉,返回新的内存块位置。
如果申请失败,将返回NULL,此时,原来的指针仍然有效。
67. 67. 67. 类成员初始化方式,构造函数的执行顺序 ,为什么用成员初始化列表会快一些
- 赋值初始化,通过在函数体内进行赋值初始化;列表初始化,在冒号后使用初始化列表进行初始化。
- 这两种方式的主要区别在于:对于在函数体中初始化,是在所有的数据成员被分配内存空间后才进行的。列表初始化是给数据成员分配内存空间时就进行初始化,就是说分配一个数据成员只要冒号后有此数据成员的赋值表达式(此表达式必须是括号赋值表达式),那么分配了内存空间后在进入函数体之前给数据成员赋值,就是说初始化这个数据成员此时函数体还未执行。
- 一个派生类构造函数的执行顺序如下:
① 虚拟基类的构造函数(多个虚拟基类则按照继承的顺序执行构造函数)。
② 基类的构造函数(多个普通基类也按照继承的顺序执行构造函数)。
③ 类类型的成员对象的构造函数(按照初始化顺序)。
④ 派生类自己的构造函数。 - 方法一是在构造函数当中做赋值的操作,而方法二是做纯粹的初始化操作。我们都知道,C++的赋值操作是会产生临时对象的。临时对象的出现会降低程序的效率。
68. 68. 68. 有哪些情况必须用到成员列表初始化?作用是什么
- 必须使用成员初始化的四种情况
① 当初始化一个引用成员时;
② 当初始化一个常量成员时;
③ 当调用一个基类的构造函数,而它拥有一组参数时;
④ 当调用一个成员类的构造函数,而它拥有一组参数时;
2) 成员初始化列表做了什么
① 编译器会一一操作初始化列表,以适当的顺序在构造函数之内安插初始化操作。
② 初始化列表中的初始化顺序是由类中的成员声明顺序决定的,不是由初始化列表的顺序决定的。
69. 69. 69. C++中新增了string,它与C语言中的 char *
有什么区别吗?它是如何实现的
string继承自basic_string,其实是对 char *
进行了封装,封装的string包含了 char *
数组,容量,长度等等属性。
string可以进行动态扩展,在每次扩展的时候另外申请一块原空间大小两倍的空间,然后将原字符串拷贝过去,并加上新增的内容。
70. 70. 70. 什么是内存泄露,如何检测与避免
- 内存泄露
一般我们常说的内存泄漏是指堆内存的泄漏。堆内存是指程序从堆中分配的,大小任意的(内存块的大小可以在程序运行期决定)内存块,使用完后必须显式释放的内存。应用程序般使用malloc,、realloc、new等函数从堆中分配到块内存,使用完后,程序必须负责相应的调用free或delete释放该内存块,否则,这块内存就不能被再次使用,这时我们就说这块内存泄漏了。 - 计数法:使用new或者malloc时,让该数+1,delete或free时,该数-1,程序执行完打印这个计数,如果不为0则表示存在内存泄露。
- 一定要将基类的析构函数声明为虚函数,对象数组的释放一定要用delete[],有new就有delete,有malloc就有free,保证它们一定成对出现。
- 检测工具
Linux下可以使用Valgrind工具,Windows下可以使用CRT库。
71. 71. 71. 对象复用的了解,零拷贝的了解
对象复用
对象复用其本质是一种设计模式:Flyweight享元模式。通过将对象存储到“对象池”中实现对象的重复利用,这样可以避免多次创建重复对象的开销,节约系统资源。
零拷贝
零拷贝就是一种避免 CPU 将数据从一块内存拷贝到另外一块内存的技术。
零拷贝技术可以减少数据拷贝和共享总线操作的次数。
在C++中,vector的一个成员函数emplace_back()很好地体现了零拷贝技术,它跟push_back()函数一样可以将一个元素插入容器尾部,区别在于:使用push_back()函数需要调用拷贝构造函数和移动构造函数,而使用emplace_back()插入的元素原地构造,不需要触发拷贝构造和移动构造,效率更高。
72. 72. 72. 介绍面向对象的三大特性,并且举例说明
三大特性:继承、封装和多态
继承
让某种类型对象获得另一个类型对象的属性和方法。它可以使用现有类的所有功能,并在无需重新编写原来的类的情况下对这些功能进行扩展。
常见的继承有三种方式:
(1)实现继承:指使用基类的属性和方法而无需额外编码的能力
(2)接口继承:指仅使用属性和方法的名称、但是子类必须提供实现的能力
(3)可视继承:指子窗体(类)使用基窗体(类)的外观和实现代码的能力(C++里好像不怎么用)
例如,将人定义为一个抽象类,拥有姓名、性别、年龄等公共属性,吃饭、睡觉、走路等公共方法,在定义一个具体的人时,就可以继承这个抽象类,既保留了公共属性和方法,也可以在此基础上扩展跳舞、唱歌等特有方法
封装
数据和代码捆绑在一起,避免外界干扰和不确定性访问。
封装,也就是把客观事物封装成抽象的类,并且类可以把自己的数据和方法只让可信的类或者对象操作,对不可信的进行信息隐藏,例如:将公共的数据或方法使用public修饰,而不希望被访问的数据或方法采用private修饰。
多态
同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为(重载实现编译时多态,虚函数实现运行时多态)。
允许将子类类型的指针赋值给父类类型的指针,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。
实现多态有二种方式:覆盖(override),重载(overload)。
覆盖:是指子类重新定义父类的虚函数的做法。
重载:是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。
例如:基类是一个抽象对象——人,那教师、运动员也是人,而使用这个抽象对象既可以表示教师、也可以表示运动员。
73. 73. 73. 成员初始化列表的概念,为什么用它会快一些
成员初始化列表的概念
在类的构造函数中,不在函数体内对成员变量赋值,而是在函数体前面使用冒号和初始化列表赋值
效率
用初始化列表会快一些的原因是,对于类型,它少了一次调用构造函数的过程,而在函数体中赋值则会多一次调用。而对于内置数据类型则没有差别。
74. 74. 74. C++ 的 四 种 强 制 转 换 reinterpret_cast、const_cast、static_cast、dynamic_cast
reinterpret_cast
reinterpret_cast<type-id> (expression)
在指针或引用类型之间进行强制转换,执行的是逐比特复制的操作。
const_cast
const_cast<type-id>(expression)
该运算符用来去除类型的const或volatile属性。
用法如下:
常量指针被转化成非常量的指针,并且仍然指向原来的对象
常量引用被转换成非常量的引用,并且仍然指向原来的对象
const_cast一般用于修改底指针。如const char *
形式
static_cast
static_cast <type-id> (expression)
该运算符用于转换风险比较低的转换,比如整型和浮点型,没有运行时类型检查来保证转换的安全性。
它主要有如下几种用法:
(1)用于类层次结构中基类(父类)和派生类(子类)之间指针或引用的转换进行上行转换(把派生类的指针或引用转换成基类表示)是安全的。进行下行转换(把基类指针或引用转换成派生类表示)时,由于没有动态类型检查,所以是不安全的。
(2)用于基本数据类型之间的转换,如把int转换成char,把int转换成enum。这种转换的安全性也要开发人员来保证。
(3)把空指针转换成其他类型的指针
(4)把其他指针类型的表达式转换成void*
类型
dynamic_cast
有类型检查,基类向派生类转换比较安全,但是派生类向基类转换则不太安全。
dynamic_cast运算符可以在执行期决定真正的类型,表达式必须是多态类型。如果下行转换是安全的(如果基类指针或者引用确实指向一个派生类对象)这个运算符会传回适当转型过的指针。如果下行转换不安全,这个运算符会传回空指针(基类指针或者引用没有指向一个派生类对象)
dynamic_cast主要用于类层次间的上行转换和下行转换,还可以用于类之间的交叉转换。在类层次间进行上行转换时,dynamic_cast和static_cast。在进行下行转换时的效果是一样的,dynamic_cast具有类型检查的功能,比static_cast更安全。
75. 75. 75. C++函数调用的压栈过程
函数的调用过程:
1)从栈空间分配存储空间
2)从实参的存储空间复制值到形参栈空间
3)进行运算
形参在函数未调用之前都是没有分配存储空间的,在函数调用结束之后,形参弹出栈空间,清除形参空间。
数组作为参数的函数调用方式是地址传递,形参和实参都指向相同的内存空间,调用完成后,形参指针被销毁,但是所指向的内存空间依然存在,不能也不会被销毁。
当函数有多个返回值的时候,不能用普通的 return 的方式实现,需要通过传回地址的形式进行,即地址/指针传递。
76. 76. 76. 说说移动构造函数
- 我们用对象a初始化对象b后,对象a我们就不在使用了,但是对象a的空间在析构之前还在,拷贝构造函数实际上就是把a对象的内容复制一份到b中,那么我们可以直接使用a的空间,这样就避免了新的空间的分配,降低了构造的成本。这就是移动构造函数设计的初衷;
- 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制。浅层复制之所以危险,是因为两个指针共同指向一片内存空间,若第一个指针将其释放,另一个指针的指向就不合法了。所以我们只要避免第一个指针释放空间就可以了。避免的方法就是将第一个指针(比如a->value)置为NULL,这样在调用析构函数的时候,由于有判断是否为NULL的语句,所以析构a的时候并不会回收a-value指向的空间;
- 移动构造函数的参数和拷贝构造函数不同,拷贝构造函数的参数是一个左值引用,但是移动构造函数的初值是一个右值引用。意味着,移动构造函数的参数是一个右值或者将亡值的引用。也就是说,只用用一个右值,或者将亡值初始化另一个对象的时候,才会调用移动构造函数。而那个move语句,就是将一个左值变成一个将亡值。
77. 77. 77. C++中将临时变量作为返回值时的处理过程
临时变量在函数调用过程中是被压到程序进程的栈中的,当函数退出时,临时变量出栈,被销毁,但是临时变量占用的内存空间没有被清空,可以被分配给其他变量,所以有可能在函数退出时,该内存已经被修改了,对于临时变量来说已经是没有意义的值了。
函数调用结束后,返回值被临时存储到寄存器中,并没有放到堆或栈中,也就是说与内存没有关系了。当退出函数的时候,临时变量可能被销毁,但是返回值却被放到寄存器中,与临时变量的生命周期没有关系。
78. 78. 78. 如何获得结构成员相对于结构开头的字节偏移量
使用<cstddef>
头文件中的offsetof宏
struct A
{
int a;
double b;
long long c;
};
int main() {
std::cout << offsetof(A, a) << std::endl;//0
std::cout << offsetof(A, b) << std::endl;//8
std::cout << offsetof(A, c) << std::endl;//16
}
79. 79. 79. 静态类型和动态类型,静态绑定和动态绑定的介绍
静态类型:对象在声明时采用的类型,在编译期既已确定;
动态类型:通常是指一个指针或引用目前所指对象的类型,是在运行期决定的;
静态绑定:绑定的是静态类型,所对应的函数或属性依赖于对象的静态类型,发生在编译期;
动态绑定:绑定的是动态类型,所对应的函数或属性依赖于对象的动态类型,发生在运行期;
静态绑定和动态绑定的区别:
(1)静态绑定发生在编译期,动态绑定发生在运行期。
(2)对象的动态类型可以更改,但是静态类型无法更改。
(3)动态绑定可以实现多态。
(4)在继承体系中只有虚函数使用的是动态绑定,其他的全部是静态绑定。
绝对不要重新定义继承而来的非虚(non-virtual)函数,因为这样导致函数调用由对象声明时的静态类型确定了,而和对象本身脱离了关系,没有多态,也这将给程序留下不可预知的隐患和莫名其妙的BUG。
80. 80. 80. 引用是否能实现动态绑定,为什么可以实现
可以。因为引用本质上也是通过指针的解引用来实现的。
引用在创建的时候必须初始化,在访问虚函数时,编译器会根据其所绑定的对象类型决定要调用哪个函数。注意只能调用虚函数。
只有虚函数才具有动态绑定。
81. 81. 81. 全局变量和局部变量有什么区别
生命周期不同:全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;
使用方式不同:通过声明后全局变量在程序的各个部分都可以用到;局部变量分配在堆栈区,只能在局部使用。
操作系统和编译器通过内存分配的位置可以区分两者,全局变量分配在全局数据段并且在程序开始运行的时候被加载。局部变量则分配在堆栈里面 。
82. 82. 82. 指针加减计算要注意什么
指针加减本质是对其所指地址的移动,移动的步长跟指针的类型是有关系的,因此在涉及到指针加减运算需要十分小心,加多或者减多都会导致指针指向一块未知的内存地址,如果再进行操作就会很危险。
指针每移动一位,它实际跨越的内存间隔是指针类型的长度。
83. 83. 83. 怎样判断两个浮点数是否相等
对两个浮点数判断大小和是否相等不能直接用 = = == ==来判断,会出错!明明相等的两个数比较反而是不相等!对于两个浮点数比较只能通过相减并与预先设定的精度比较,记得要取绝对值!浮点数与0的比较也应该注意。与浮点数的表示方式有关。
84. 84. 84. C++中的指针参数传递和引用参数传递有什么区别?底层原理你知道吗
- 指针参数传递本质上是值传递,它所传递的是一个地址值。值传递过程中,函数的形参作为局部变量处理,会在栈中开辟内存空间以存放传递进来的实参值,从而形成了实参的一个副本。
值传递的特点是,函数对形参的任何操作都是作为局部变量进行的,不会影响实参变量的值(形参指针变了,实参指针不会变)。
2) 引用参数传递过程中,函数的形参也作为局部变量在栈中开辟了内存空间,但是这时存放的是由函数放进来的实参的地址。函数对形参的任何操作都被处理成间接寻址,即通过栈中存放的地址访问函数中的实参变量。因此,对形参的任何操作都会影响函数中的实参变量。
3) 引用传递和指针传递是不同的,虽然他们都是在被调函数栈空间上的一个局部变量,但是任何对于引用参数的处理都会通过一个间接寻址的方式操作到主调函数中的相关变量。
而对于指针传递的参数,如果改变函数中形参的指针地址,它对实参没有影响。如果想通过指针参数传递来改变函数中的实参,那就得使用指向指针的指针或者指针引用。
4) 从编译的角度来讲,程序在编译时分别将指针和引用添加到符号表上,符号表中记录的是变量名及变量所对应地址。指针变量在符号表上对应的地址值为指针变量的地址值,而引用在符号表上对应的地址值为引用对象的地址值(与实参名字不同,地址相同)。符号表生成之后就不会再改,因此指针可以改变其指向的对象,而引用对象则不能修改。
85. 85. 85. 类如何实现只能静态分配和只能动态分配
- 前者是把new、delete运算符重载为private属性。后者是把构造、析构函数设为protected属性,再用子类来动态创建。
- 建立类的对象有两种方式:
① 静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存。
② 动态建立,动态建立一个对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象。
3) 只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上,可以将new运算符设为私有。
86. 86. 86. 如果想将某个类用作基类,为什么该类必须定义而非声明
派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类必须知道他们是什么。所以必须定义而非声明。
87. 87. 87. 继承机制中对象之间如何转换?
- 向上类型转换
将派生类指针或引用转换为基类的指针或引用被称为向上类型转换,向上类型转换会自动进行,而且向上类型转换是安全的。
- 向下类型转换
将基类指针或引用转换为派生类指针或引用被称为向下类型转换,向下类型转换不会自动进行,因为一个基类对应几个派生类,所以向下类型转换时不知道对应哪个派生类,所以用dynamic_cast进行向下类型转换。
88. 88. 88. 知道C++中的组合吗?它与继承相比有什么优缺点吗
继承
继承是Is a 的关系,比如说Student继承Person,则说明Student is a Person。
继承的优点是子类可以重写父类的方法来方便地实现对父类的扩展。
继承的缺点有以下几点:
①:父类的内部细节对子类是可见的。
②:子类从父类继承的方法在编译时就确定下来了,所以无法在运行期间改变从父类继承的方法或行为。
③:如果对父类的方法做了修改的话(比如增加了一个参数),则子类的方法必须做出相应的修改。所以说子类与父类是一种高耦合,违背了面向对象思想。
组合
组合也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量。
组合的优点:
①:当前对象只能通过所包含的那个对象去调用其方法,所以所包含的对象的内部细节对当前对象是不可见的。
②:当前对象与包含的对象是一个低耦合关系,如果修改包含对象的类中代码不需要修改当前对象类的代码。
③:当前对象可以在运行时动态的绑定所包含的对象。
组合的缺点:
①:容易产生过多的对象。
②:为了能组合多个对象,必须仔细对接口进行定义。
89. 89. 89. 函数指针
-
什么是函数指针?
函数指针指向的是特殊的数据类型,函数的类型是由其返回的数据类型和其参数列表共同决定的,而函数的名称则不是其类型的一部分。
一个具体函数的名字,如果后面不跟调用符号(即括号),则该名字就是该函数的指针(注意:大部分情况下,可以这么认为,但这种说法并不很严格)。 -
函数指针的声明方法
int (*pf)(const int&, const int&);
上面的pf就是一个函数指针,指向所有返回类型为int,并带有两个const int&参数的函数。注意*pf两边的括号是必须的,否则上面的定义就变成了:
int *pf(const int&, const int&);
而这声明了一个函数pf,其返回类型为int *, 带有两个const int&参数。
- 为什么有函数指针
函数与数据项相似,函数也有地址。我们希望在同一个函数中通过使用相同的形参在不同的时间使用产生不同的效果。
- 一个函数名就是一个指针,它指向函数的代码。
一个函数地址是该函数的进入点,也就是调用函数的地址。函数的调用可以通过函数名,也可以通过指向函数的指针来调用。函数指针还允许将函数作为变量传递给其他函数; - 两种方法赋值:
指针名 = 函数名; 指针名 = &函数名
90. 90. 90. 说一说你理解的内存对齐以及原因
1、 分配内存的顺序是按照声明的顺序。
2、 每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止。
3、 最后整个结构体的大小必须是里面变量类型最大值的整数倍。
添加了#pragma pack(n)后规则就变成了下面这样:
1、 偏移量是n和当前变量的大小中较小值的整数倍
2、 整体大小是n和最大变量大小中较小值的整数倍
3、 n值必须为1,2,4,8…,为其他值时就按照默认的分配规则
92. 92. 92. 结构体变量比较是否相等
(1)重载==运算符
(2)一个个比较其中的元素
(3)指针直接比较,如果保存的是同一个实例地址,则为真
93. 93. 93. define、const、typedef、inline的使用方法?他们之间有什么区别
const与#define的区别:
-
const定义的常量是变量带类型,而#define定义的只是个常数不带类型;
-
define只在预处理阶段起作用,简单的文本替换,而const在编译、链接过程中起作用;
-
define只是简单的字符串替换没有类型检查。而const是有数据类型的,是要进行判断的,可以避免一些低级错误;
-
define预处理后,占用代码段空间,const占用数据段空间;
-
const不能重定义,而define可以通过#undef取消某个符号的定义,进行重定义;
-
define独特功能,比如可以用来防止文件重复引用。
#define和typedef的区别
-
执行时间不同,typedef在编译阶段有效,typedef有类型检查的功能;#define是宏定义,发生在预处理阶段,不进行类型检查;
-
功能差异,typedef用来定义类型的别名,与struct的结合使用等。#define不只是可以为类型取别名,还可以定义常量、变量、编译开关等。
-
作用域不同,#define没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用。而typedef有自己的作用域。
define与inline的区别
-
define是关键字,inline是函数;
-
宏定义在预处理阶段进行文本替换,inline函数在编译阶段进行替换;
-
inline函数有类型检查,相比宏定义比较安全;
94. 94. 94. 你知道printf函数的实现原理是什么吗
在C/C++中,对函数参数的扫描是从后向前的。
C/C++的函数参数是通过压入堆栈的方式来给函数传参数的(堆栈是一种先进后出的数据结构),最先压入的参数最后出来,在计算机的内存中,数据有2块,一块是堆,一块是栈(函数参数及局部变量在这里),而栈是从内存的高地址向低地址生长的,控制生长的就是堆栈指针了,最先压入的参数是在最上面,就是说在所有参数的最后面,最后压入的参数在最下面,结构上看起来是第一个,所以最后压入的参数总是能够先被函数找到,因为它就在堆栈指针的上方。
printf的第一个被找到的参数就是那个字符指针,就是被双引号括起来的那一部分,函数通过判断字符串里控制参数的个数来判断参数个数及数据类型,通过这些就可算出数据需要的堆栈指针的偏移量了。
95. 95. 95. 为什么模板类一般都是放在一个h文件中
- 模板定义很特殊。由template<…>处理的任何东西都意味着编译器在当时不为它分配存储空间,它一直处于等待状态直到被一个模板实例告知。所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义。
- 在分离式编译的环境下,编译器编译某一个.cpp文件时并不知道另一个.cpp文件的存在,也不会去查找(当遇到识别不了的符号时它会寄希望于连接器)。这种模式在没有模板的情况下运行良好,但遇到模板时就傻眼了,因为模板仅在需要的时候才会实例化出来。所以,当编译器只看到模板的声明时,它不能实例化该模板,只能创建一个具有外部连接的符号并期待连接器能够将符号的地址找出来。然而当实现该模板的.cpp文件中没有用到模板的实例时,编译器懒得去实例化,所以,整个工程的.obj中就找不到一行模板实例的二进制代码,于是连接器也黔驴技穷了。
96. 96. 96. cout和printf有什么区别
cout是一个函数,cout后可以跟不同的类型,因为cout已存在针对各种类型数据的重载,所以会自动识别数据的类型。输出过程会首先将输出字符放入缓冲区,然后输出到屏幕。
cout是有缓冲输出,flush立即强迫缓冲输出。
printf是无缓冲输出。有输出时立即输出。
97. 97. 97. 你知道重载运算符吗
1、 我们只能重载已有的运算符,而无权发明新的运算符;对于一个重载的运算符,其优先级和结合律与内置类型一致才可以;不能改变运算符参数个数;
2、 两种重载方式:成员运算符和非成员运算符,成员运算符比非成员运算符少一个参数;下标运算符、箭头运算符必须是成员运算符;
3、 引入运算符重载,是为了实现类的多态性;
4、 当重载的运算符是成员函数时,this绑定到左侧运算符对象。成员运算符函数的参数数量比运算符对象的数量少一个;至少含有一个类类型的参数;
5、 从参数的个数推断到底定义的是哪种运算符,某些运算符既是一元运算符又是二元运算符(+,-,*,&);
6、 下标运算符必须是成员函数,下标运算符通常以所访问元素的引用作为返回值,同时最好定义下标运算符的常量版本和非常量版本;
7、 箭头运算符必须是类的成员,解引用通常也是类的成员;重载的箭头运算符必须返回类的指针;
98. 98. 98. 当程序中有函数重载时,函数的匹配原则和顺序是什么
- 名字查找
- 确定候选函数
- 从候选函数中选出可以被实参调用的可行函数
- 在可行函数中寻找最佳匹配,类型越接近,匹配的越好。该函数每个实参的匹配都不劣于其他可行函数需要的匹配,至少有一个实参的匹配优于其他可行函数提供的匹配。满足以上两个条件中的一个就算匹配成功,否则发生二义性调用
候选函数包括非成员版本和内置版本,如果左侧对象是类类型,则运算符的重载版本也在候选函数之内。当我们调用一个命名的函数时,具有该名字的成员函数和非成员函数不会彼此重载,因为调用形式不同。当我们通过类类型的对象调用函数时 ,只考虑成员函数;如果在表达式中使用重载的运算符时,二者都应该考虑。
候选函数具有两个特征,一是同名,二是声明在调用点可见。
99. 99. 99. 定义和声明的区别
如果是指变量的声明和定义:
声明是仅仅告诉编译器,有个某类型的变量会被使用,但是编译器并不会为它分配任何内存。而定义就是分配了内存。
如果是指函数的声明和定义:
声明:一般在头文件里,对编译器说:这里我有一个函数,让编译器知道这个函数的存在。
定义:一般在源文件里,具体就是函数的实现过程,写明函数体。
100. 100. 100. 全局变量和static变量的区别
1、全局变量(外部变量)的说明之前再冠以static就构成了静态的全局变量。
全局变量本身就是静态存储方式,静态全局变量也是静态存储方式。
这两者在存储方式上并无不同。这两者的区别在于非静态全局变量的作用域是整个源程序,当一个源程序由多个源文件组成时,非静态的全局变量在各个源文件中都是有效的。
而静态全局变量则限制了其作用域,即只在定义该变量的源文件内有效,在同一源程序的其它源文件中不能使用它。由于静态全局变量的作用域限于一个源文件内,只能为该源文件内的函数公用,因此可以避免在其他源文件中引起错误。
static全局变量与普通的全局变量的区别是static全局变量只初始化一次,防止在其他文件单元被引用。
2.static函数与普通函数有什么区别?
static函数与普通的函数作用域不同。只在当前源文件中使用的函数应该声明为static,static函数应该在当前源文件中说明和定义。
对于可在当前源文件以外使用的函数应该在一个头文件中说明,要使用这些函数的源文件要包含这个头文件。
static函数与普通函数最主要区别是static函数在内存中只有一份,存放在静态存储区,避免了出栈入栈带来的额外开销,普通静态函数在每个被调用中维持一份拷贝程序的局部变量存在于堆栈中,
101. 101. 101. 静态成员与普通成员的区别是什么
- 生命周期
静态成员变量从类被加载开始到类被卸载,一直存在。
普通成员变量只有在类创建对象后才开始存在,对象结束,它的生命期结束。
2) 共享方式
静态成员变量是全类共享;普通成员变量是每个对象单独享用的。
3) 定义位置
普通成员变量存储在栈或堆中,而静态成员变量存储在静态全局区。
4) 初始化位置
普通成员变量在类中初始化;静态成员变量在类外初始化。
5) 默认实参
可以使用静态成员变量作为默认实参,而不能使用普通成员作为默认实参。因为静态成员变量在函数解析之前就已经存在,而普通成员不保证在解析函数时,已经准备好。
102. 102. 102. 说一下你理解的 ifdef,endif代表着什么
-
一般情况下,源程序中所有的行都参加编译。但是有时希望当满足某条件时对一组语句进行编译,而当条件不满足时则编译另一组语句。也就是对一部分内容指定编译的条件,这就是“条件编译”。
-
条件编译命令最常见的形式为:
#ifdef dejavu
...
#else
...
#endif
它的作用是:当标识符已经被定义过(一般是用#define命令定义),则对程序段1进行编译,否则编译程序段2。其中#else部分也可以没有。
- 在一个大的软件工程里面,可能会有多个文件同时包含一个头文件,当这些文件编译链接成一个可执行文件上时,就会出现大量“重定义”错误。在头文件中使用#define、#ifndef、#ifdef、#endif能避免头文件重定义。
103. 103. 103. 隐式转换,如何消除隐式转换
1、C++的基本类型中并非完全的对立,部分数据类型之间是可以进行隐式转换的。所谓隐式转换,是指不需要用户干预,编译器私下进行的类型转换行为。很多时候用户可能都不知道进行了哪些转换。
2、C++面向对象的多态特性,就是通过父类的类型实现对子类的封装。通过隐式转换,你可以直接将一个子类的对象使用父类的类型进行返回。在比如,数值和布尔类型的转换,整数和浮点数的转换等。某些方面来说,隐式转换给C++程序开发者带来了不小的便捷。
3、 基本数据类型的转换以取值范围作为转换基础(保证精度不丢失)。隐式转换发生在从小->大的转换中。比如从char转换为int。从int->long。子类对象可以隐式的转换为父类对象。
4、C++中提供了explicit关键字,在构造函数声明的时候加上explicit关键字,能够禁止隐式转换。
5、如果构造函数只接受一个参数,则它实际上定义了转换为此类类型的隐式转换机制。可以通过将构造函数声明为explicit来防止隐式类型转换的发生,关键字explicit只对一个实参的构造函数有效,需要多个实参的构造函数不能用于执行隐式转换,所以无需将这些构造函数指定为explicit。
104. 104. 104. C++如何处理多个异常的
- C++中的异常情况:
语法错误(编译错误):比如变量未定义、括号不匹配、关键字拼写错误等等编译器在编译时能发现的错误,这类错误可以及时被编译器发现,而且可以及时知道出错的位置及原因,方便改正。
运行时错误:比如数组下标越界、系统内存不足等等。这类错误不易被程序员发现,它能通过编译且能进入运行,但运行时会出错,导致程序崩溃。为了有效处理程序运行时错误,C++中引入异常处理机制来解决此问题。
- C++异常处理机制:
异常处理基本思想:执行一个函数的过程中发现异常,可以不用在本函数内立即进行处理, 而是抛出该异常,让函数的调用者直接或间接处理这个问题。
C++异常处理机制由3个模块组成:try(检查)、throw(抛出)、catch(捕获),抛出异常的语句格式为:throw 表达式;如果try块中程序段发现了异常则抛出异常。
105. 105. 105. 如何在不使用额外空间的情况下,交换两个数?你有几种方法
//算术
x = x + y;
y = x - y;
x = x - y;
//异或,只能用于int和char
x = x ^ y;
y = x ^ y;
x = x ^ y;
106. 106. 106. 你知道strcpy和memcpy的区别是什么吗
1、复制的内容不同。strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类等。
2、复制的方法不同。strcpy不需要指定长度,它遇到被复制字符的串结束符"\0"才结束,所以容易溢出。memcpy则是根据其第3个参数决定复制的长度。
3、用途不同。通常在复制字符串时用strcpy,而需要复制其他类型数据时则一般用memcpy
107. 107. 107. 程序在执行int main(int argc, char *argv[])时的内存结构,你了解吗
参数的含义是程序在命令行下运行的时候,需要输入argc 个参数,每个参数是以char_类型输入的,依次存在数组里面,数组是 argv[],所有的参数在char_指向的内存中,第一个参数是程序的名称。
109. 109. 109. 如果有一个空类,它会默认添加哪些函数
Empty();//构造函数
Empty(const Empty&);//拷贝构造函数
~Empty();//析构函数
Empty& operator=(const Empty&);//拷贝赋值运算符
Empty(Empty&&)noexcept;//移动构造函数
Empty& operator=(Empty&&)noexcept;//移动赋值运算符
112. 112. 112. 你什么情况用指针当参数,什么时候用引用,为什么
指针和引用在性能上完全相同,因为引用内部的实现是指针。但是使用指针可能会使程序很难阅读,或者存在更多的危险。
-
使用引用参数的主要原因有两个:
程序员能修改调用函数中的数据对象。
通过传递引用而不是整个对象,可以提高程序的运行速度。 -
对于使用引用的值而不做修改的函数:
如果数据对象很小,如内置数据类型或者小型结构,则按照值传递。
如果数据对象是数组,则只能使用指针,并且指针声明为指向const的指针。
如果数据对象是较大的结构,则使用const指针或者引用,已提高程序的效率。这样可以节省结构所需的时间和空间。
如果数据对象是类对象,则使用const引用(传递类对象参数的标准方式是按照引用传递)。 -
对于修改函数中数据的函数:
如果数据对象是数组,则只能使用指针。
如果数据对象是内置数据类型或结构体,则使用引用或者指针。
如果数据是类对象,则使用引用。
113. 113. 113. 你知道静态绑定和动态绑定吗?讲讲
- 对象的静态类型:对象在声明时采用的类型。是在编译期确定的。
- 对象的动态类型:目前所指对象的类型。是在运行期决定的。对象的动态类型可以更改,但是静态类型无法更改。
- 静态绑定:绑定的是对象的静态类型,函数依赖于对象的静态类型,发生在编译期。
- 动态绑定:绑定的是对象的动态类型,函数依赖于对象的动态类型,发生在运行期。
114. 114. 114. 如何设计一个类计算子类的个数
1、为类设计一个static静态变量count作为计数器。
2、类定义结束后初始化count。
3、在构造函数中对count进行+1。
4、 设计拷贝构造函数,在进行拷贝构造函数中进行count +1,操作。
5、设计拷贝赋值运算符,在运算符中对count+1操作。
6、在析构函数中对count进行-1。
115. 115. 115. 怎么快速定位错误出现的地方
1、如果是简单的错误,可以直接双击错误列表里的错误项或者生成输出的错误信息中带行号的地方就可以让编辑窗口定位到错误的位置上。
2、对于复杂的模板错误,最好使用生成输出窗口。多数情况下出现错误的位置是最靠后的引用位置。如果这样确定不了错误,就需要先把自己写的代码里的引用位置找出来,然后逐个分析了。
116. 116. 116. 成员初始化列表会在什么时候用到?它的调用过程是什么
- 当初始化一个引用成员变量时。
- 初始化一个const成员变量时。
- 当调用一个基类的构造函数,而构造函数拥有一组参数时。
- 当调用一个成员类的构造函数,而他拥有一组参数。
- 编译器会一一操作初始化列表,以适当顺序在构造函数之内安插初始化操作,并且在任何显示用户代码前。list中的项目顺序是由类中的成员声明顺序决定的,不是初始化列表中的排列顺序决定的。
117. 117. 117. 说一说strcpy、sprintf与memcpy这三个函数的不同之处
操作对象不同
① strcpy的两个操作对象均为字符串
② sprintf的操作源对象可以是多种数据类型,目的操作对象是字符串。
③ memcpy的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。
执行效率不同
memcpy最高,strcpy次之,sprintf的效率最低。
实现功能不同
① strcpy主要实现字符串变量间的拷贝。
② sprintf主要实现其他数据类型格式到字符串的转化。
③ memcpy主要是内存块间的拷贝。
118. 118. 118. 将引用作为函数参数有哪些好处
- 传递引用与传递指针的效果是一样的。这时,函数的形参就成为实参变量或对象的一个别名来使用,所以在函数中对形参变量的操作就是对其相应的实参的操作。
- 使用引用传递函数的参数,在内存中并没有产生实参的副本,它是直接对实参操作;而使用值传递传递函数的参数,当发生函数调用时,需要给形参分配存储单元,形参变量是实参变量的副本;如果传递的是对象,还将调用拷贝构造函数。因此,当参数传递的数据较大时,用引用比值传递参数的效率和所占空间都好。
- 使用指针作为函数的参数虽然也能达到与使用引用的效果,但是,在函数中同样要给形参分配存储单元,且需要重复使用"指针变量名"的形式进行运算,这很容易产生错误且程序的阅读性较差;另一方面,在主调函数的调用点处,必须用变量的地址作为实参。而引用更容易使用,更清晰。
119. 119. 119. 你知道数组和指针的区别吗
- 数组在内存中是连续存放的,开辟一块连续的内存空间;数组所占存储空间:sizeof(数组名),数组大小:sizeof(数组名)/sizeof(数组元素数据类型);
- 用运算符sizeof 可以计算出数组的容量(字节数)。而对指针p执行sizeof§,得到的是一个指针变量的字节数,而不是p所指的内存容量。
- 编译器为了简化对数组的支持,实际上是利用指针实现了对数组的支持。具体来说,就是将表达式中的数组元素引用转换为指针加偏移量的引用。
- 在向函数传递参数的时候,如果实参是一个数组,那用于接受的形参为对应的指针。也就是传递过去是数组的首地址而不是整个数组,能够提高效率。
120. 120. 120. 如何阻止一个类被实例化?有哪些方法
将类定义为抽象基类或者将构造函数声明为private或delete;
121. 121. 121. 如何禁止程序自动生成拷贝构造函数
- 为了阻止编译器默认生成拷贝构造函数和拷贝赋值函数,我们需要手动去重写这两个函数,某些情况下,为了避免调用拷贝构造函数和拷贝赋值函数,我们需要将他们设置成private或者delete,防止被调用。
- 类的成员函数和friend函数还是可以调用private函数,如果这个private函数只声明不定义,则会产生一个连接错误。
- 针对上述两种情况,我们可以定一个base类,在base类中将拷贝构造函数和拷贝赋值函数设置成private,那么派生类中编译器将不会自动生成这两个函数,且由于base类中该函数是私有的,因此,派生类将阻止编译器执行相关的操作。
122. 122. 122. 你知道Debug和release的区别是什么吗
- debug调试版本,包含调试信息,所以容量比Release大很多,并且不进行任何优化(优化会使调试复杂化,因为源代码和生成的指令间关系会更复杂),便于程序员调试。Debug模式下生成两个文件,除了.exe或.dll文件外,还有一个.pdb文件,该文件记录了代码中断点等调试信息。
- release发布版本,不对源代码进行调试,编译时对应用程序的速度进行优化,使得程序在代码大小和运行速度上都是最优的。(调试信息可在单独的PDB文件中生成)。Release模式下生成一个文件.exe或.dll文件。
- 实际上,Debug 和 Release 并没有本质的界限,他们只是一组编译选项的集合,编译器只是按照预定的选项行动。事实上,我们甚至可以修改这些选项,从而得到优化过的调试版本或是带跟踪语句的发布版本。
123. 123. 123. main函数的返回值有什么值得考究之处吗
main函数是程序运行过程的入口,main函数返回值类型必须是int,这样返回值才能传递给程序激活者(如操作系统)表示程序是正常退出还是由于其他原因而退出的。
main(int argc, char **argv) 参数的传递。对于参数的处理,一般会调用getopt函数处理,但实践中,这仅仅是一部分,不会经常用到的技能点。
124. 124. 124. strcpy函数和strncpy函数的区别?哪个函数更安全
-
函数原型
char* strcpy(char* strDest, const char* strSrc); char* strncpy(char* strDest, const char* strSrc, int pos);
-
strcpy函数: 如果参数 dest 所指的内存空间不够大,可能会造成缓冲溢出的错误情况,在编写程序时请特别留意,或者用strncpy()来取代。
strncpy函数:用来复制源字符串的前n个字符,src 和 dest 所指的内存区域不能重叠,且 dest 必须有足够的空间放置n个字符。
-
如果目标长 > \gt > 指定长 > \gt > 源长,则将源长全部拷贝到目标长,自动加上 ’\0’ 如果指定长 < \lt < 源长,则将源长中按指定长度拷贝到目标字符串,不包括’\0’,如果指定长 > \gt > 目标长,运行时错误 ;
125. 125. 125. static_cast比C语言中的转换强在哪里
-
更加安全,对于不合法的转换会报错。
int a = 10; char c = 'a'; int* q = (int*)&c;//正确 int* p = static_cast<int*>(&c);//报错
-
更直接明显,能够一眼看出是什么类型转换为什么类型,容易找出程序中的错误;可清楚地辨别代码中每个显式的强制转换;可读性更好,能体现程序员的意图。
126. 126. 126. 成员函数里memset(this,0,sizeof(*this))会发生什么
- 有时候类里面定义了很多int,char,struct等c语言里的那些类型的变量,我习惯在构造函数中将它们初始化为0,但是一句句的写太麻烦,所以直接就memset(this, 0, sizeof *this);将整个对象的内存全部置为0。对于这种情形可以很好的工作。
- 类含有虚函数表:这么做会破坏虚函数表,后续对虚函数的调用都将出现异常。
- 类中含有C++类型的对象:例如,类中定义了一个list的对象,由于在构造函数体的代码执行之前就对list对象完成了初始化,假设list在它的构造函数里分配了内存,那么我们这么一做就破坏了list对象的内存。
127. 127. 127. 你知道回调函数吗?它的作用
- 当发生某种事件时,系统或其他函数将会自动调用你定义的一段函数。
- 回调函数就相当于一个中断处理函数,由系统在符合你设定的条件时自动调用。为此,你需要做三件事:1,声明;2,定义;3,设置触发条件,就是在你的函数中把你的回调函数名称转化为地址作为一个参数,以便于系统调用。
- 回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针所指向的函数被调用时,我们就说这是回调函数。
- 可以把调用者与被调用者分开。调用者不关心谁是被调用者,它只需要知道存在一个具有某种特定原型、某些限制条件(如返回值为int)的被调函数。
128. 128. 128. 动态链接和静态链接
静态链接
函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。
空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本;
更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
动态链接
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多份副本,而是这多个程序在执行时共享同一份副本。
更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。
性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。
129. 129. 129. 为什么友元函数必须在类内部声明
因为编译器必须能够读取这个类的声明以理解这个数据类型的大小、行为等方面的所有规则。有一条规则在任何关系中都很重要,那就是谁可以访问我的私有部分。
130. 130. 130. 友元函数和友元类的基本情况
友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制。通过友元,一个不同函数或者另一个类中的成员函数可以访问类中的私有成员和保护成员。友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。
1)友元函数
友元函数是定义在类外的普通函数,不属于任何类,可以访问其他类的私有成员。一个函数可以是多个类的友元函数,但是每个类中都要声明这个函数。
2)友元类
友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的私有成员和保护成员。但是另一个类里面也要相应的进行声明。
使用友元类时注意:
(1) 友元关系不能被继承。
(2) 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
(3) 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的声明。
131. 131. 131. C语言实现继承和多态
#include <iostream>
struct AnimalClass {
void (*speak)(struct Animal *);
void (*eat)(struct Animal *);
void (*action)(struct Animal *);
};
struct Animal {
const AnimalClass *class_;//虚函数表
void (*die)(struct Animal *);
};
struct Fish {
Animal animal;
};
struct Bird {
Animal animal;
};
void die(Animal *animal) {
std::cout << "I'm die" << std::endl;
}
void speak(Animal *self) {
const AnimalClass kAnimalClass = *self->class_;
if(kAnimalClass.speak)
kAnimalClass.speak(self);
else
std::cerr << "I can't speak" << std::endl;
}
void eat(Animal *self) {
const AnimalClass kAnimalClass = *self->class_;
if(kAnimalClass.eat)
kAnimalClass.eat(self);
else
std::cerr << "I can't eat" << std::endl;
}
void action(Animal *self) {
const AnimalClass kAnimalClass = *self->class_;
if(kAnimalClass.action)
kAnimalClass.action(self);
else
std::cerr << "I can't action" << std::endl;
}
void FishSpeak(Animal *self) {
std::cout << "I'm FishSpeak" << std::endl;
}
void FishEat(Animal *self) {
std::cout << "I'm FishEat" << std::endl;
}
void FishAction(Animal *self) {
std::cout << "I'm FishAction" << std::endl;
}
void BirdSpeak(Animal *self) {
std::cout << "I'm BirdSpeak" << std::endl;
}
void BirdEat(Animal *self) {
std::cout << "I'm BirdEat" << std::endl;
}
void BirdAction(Animal *self) {
std::cout << "I'm BirdAction" << std::endl;
}
const AnimalClass birdClass = {&BirdSpeak, &BirdEat, &BirdAction};
const AnimalClass fishClass = {&FishSpeak, &FishEat, &FishAction};
int main()
{
Fish fish = {&fishClass, &die};
Bird bird = {&birdClass, &die};
eat((Animal*)&fish);
eat((Animal*)&bird);
speak((Animal*)&fish);
speak((Animal*)&bird);
action((Animal*)&fish);
action((Animal*)&bird);
die((Animal*)&fish);
die((Animal*)&bird);
}
132. 132. 132. 目标文件的分类
可执行目标文件:可以直接在内存中执行。
可重定位目标文件:可与其它可重定位目标文件在链接阶段合并,创建一个可执行目标文件。
共享目标文件:这是一种特殊的可重定位目标文件,可以在运行时被动态加载进内存并链接。
133. 133. 133. 介绍一下几种典型的锁
读写锁
1、多个读者可以同时进行读
2、写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
3、写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)
互斥锁
一次只能一个线程拥有互斥锁,其他线程只有等待。
互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。互斥锁实际的效率还是可以让人接受的,加锁的时间大概100ns左右,而实际上互斥锁的一种可能的实现是先自旋一段时间,当自旋的时间超过阀值之后再将线程投入睡眠中,因此在并发运算中使用互斥锁(每次占用锁的时间很短)的效果可能不亚于使用自旋锁。
条件变量
互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。
自旋锁
如果线程无法取得锁,线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。
134. 134. 134. delete
和delete[]
区别
delete
只会调用一次析构函数。
delete[]
会调用数组中每个元素的析构函数。
135. 135. 135. 为什么不能把所有的函数写成内联函数
内联函数以代码复杂为代价,它以省去函数调用的开销来提高执行效率。所以一方面如果内联函数体内代码执行时间相比函数调用开销较大,则没有太大的意义;另一方面每一处内联函数的调用都要复制代码,消耗更多的内存空间,因此以下情况不宜使用内联函数:
1、函数体内的代码比较长,将导致内存消耗代价。
2、函数体内有循环,函数执行时间要比函数调用开销大。
136. 136. 136. 为什么C++没有垃圾回收机制?这点跟Java不太一样
1、首先,实现一个垃圾回收器会带来额外的空间和时间开销。你需要开辟一定的空间保存指针的引用计数和对他们进行标记mark。然后需要单独开辟一个线程在空闲的时候进行free操作。
2、垃圾回收会使得C++不适合进行很多底层的操作。
137. 137. 137. 在进行函数参数以及返回值传递时,可以使用引用或者值传递,其中使用引用的好处有哪些
对比值传递,引用传参的好处:
1)在函数内部可以对此参数进行修改
2)不会进行拷贝,不会开辟额外内存空间,提高函数调用和运行的效率
函数的参数是形参,这个形参的作用域只是在函数体内部,实参和形参是两个不同的东西,形参只是实参的一个副本。即使函数内部有对参数的修改,也只是针对形参,也就是那个副本,实参不会有任何更改。函数一旦结束,形参生命也宣告终结,做出的修改一样没对任何变量产生影响。用引用作为返回值时在内存中不产生被返回值的副本。
但是有以下的限制:
1)不能返回局部变量的引用。因为函数返回以后局部变量就会被销毁。
2)不能返回函数内部new分配的内存的引用。虽然不存在局部变量的被动销毁问题,可对于这种情况,又面临其它尴尬局面。例如,被函数返回的引用只是作为一个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间(由new分配)就无法释放,造成内存泄漏。
3)可以返回类成员的引用,但是最好是const。因为如果其他对象可以获得该属性的非常量的引用,那么对该属性的单纯赋值就会破坏业务规则的完整性。
138. 138. 138. 函数调用过程栈的变化,返回值和参数变量哪个先入栈
1、调用者函数把被调函数所需要的参数按照与被调函数的形参顺序相反的顺序压入栈中,即:从右向左依次把被调函数所需要的参数压入栈;
2、调用者函数使用call指令调用被调函数,并把call指令的下一条指令的地址当成返回地址压入栈中(这个压栈操作隐含在call指令中);
4、在被调函数中,被调函数中的局部变量和临时变量,这些变量的地址按照定义时的顺序依次减小,即:这些变量的地址是按照栈的延伸方向排列的,先定义的变量先入栈,后定义的变量后入栈。
139. 139. 139. 类的对象存储空间
非静态成员的数据类型大小之和。
编译器加入的额外成员变量(如指向虚函数表的指针)。
为了边缘对齐优化加入的padding。
空类(无非静态数据成员)的对象的size为1, 当作为基类时, size为0。
140. 140. 140. 简要说明C++的内存分区
C++中的内存分区,分别是堆、栈、自由存储区、全局/静态存储区、常量存储区和代码区。
栈:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,效率很高,但是分配的内存容量有限。
堆:就是那些由 new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控制,一般一个new就要对应一个 delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
自由存储区:如果说堆是操作系统维护的一块内存,那么自由存储区就是C++中通过new和delete动态分配和释放对象的抽象概念。需要注意的是,自由存储区和堆比较像,但不等价。自由存储区有时候和堆是两块不同的内存,是由编译器决定的。
全局/静态存储区:全局变量和静态变量被分配到同一块内存中,共同占用同一块内存区,在该区定义的变量若没有初始化,则会被自动初始化,例如int型变量自动初始为0。
常量存储区:这是一块比较特殊的存储区,这里面存放的是常量,不允许修改。
代码区:存放函数体的二进制代码。
141. 141. 141. 什么是内存池,如何实现
内存池(Memory Pool) 是一种内存分配方式。通常我们习惯直接使用new、malloc 等申请内存,这样做的缺点在于:由于所申请内存块的大小不定,当频繁使用时会造成大量的内存碎片并进而降低性能。内存池则是在真正使用内存之前,先申请分配一定数量的、大小相等(一般情况下)的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块, 若内存块不够再继续申请新的内存。这样做的一个显著优点是尽量避免了内存碎片,使得内存分配效率得到提升。
142. 142. 142. C++中类的数据成员和成员函数内存分布情况
C++类是由结构体发展得来的,所以他们的成员变量(C语言的结构体只有成员变量)的内存分配机制是一样的。 类分为成员变量和成员函数。
一个类对象的地址就是类所包含的这一片内存空间的首地址,这个首地址也就对应具体某一个成员变量的地址。
成员函数不占用对象的内存。因为所有的函数都是存放在代码区的,不管是全局函数,还是成员函数。
静态成员函数与一般成员函数的唯一区别就是没有this指针,因此不能访问非静态数据成员。
因此所有函数都存放在代码区,静态函数也不例外。
143. 143. 143. 关于this指针你知道什么?全说出来
1、this指针是类的指针,指向对象的首地址。
2、this指针只能在成员函数中使用,在全局函数、静态成员函数中都不能用this。
3、this指针只有在成员函数中才有定义,且存储位置会因编译器不同而不同。
this指针的用处
一个对象的this指针并不是对象本身的一部分,不会影响 sizeof的结果。this作用域是在类内部,当在类的非静态成员函数中访问类的非静态成员的时候(全局函数,静态函数中不能使用this指针),编译器会自动将对象本身的地址作为一个隐含参数传递给函数。也就是说,即使你没有写上this指针,编译器在编译的时候也是加上this的,它作为非静态成员函数的隐含形参,对各成员的访问均通过this进行。
this指针的使用
一种情况就是,在类的非静态成员函数中返回类对象本身的时候,直接使用 return *this
;
另外一种情况是当形参数与成员变量名相同时用于区分,如this->n = n
。
类的this指针有以下特点
(1)this只能在成员函数中使用,全局函数、静态函数都不能使用this。实际上,成员函数默认第一个参数就是this指针。
(2) this在成员函数的开始前构造,在成员函数的结束后清除。这个生命周期同任何一个函数的参数是一样的,没有任何区别。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递进去 。
144. 144. 144. 几个this指针的易混问题
this指针是什么时候创建的
this在成员函数的开始执行前构造,在成员的执行结束后清除。
this指针存放在何处?堆、栈、全局变量,还是其他
this指针会因编译器不同而有不同的放置位置。可能是栈,也可能是寄存器,甚至全局变量。
this指针是如何传递类中的函数的?绑定?还是在函数参数的首参数就是this指针?那么,this指针又是如何找到“类实例后函数的”
大多数编译器通过ecx(寄数寄存器)寄存器传递this指针。事实上,这也是一个潜规则。一般来说,不同编译器都会遵从一致的传参规则,否则不同编译器产生的obj就无法匹配了。
在call之前,编译器会把对应的对象地址放到寄存器中。this是通过函数参数的首参来传递的。this指针在调用之前生成,类在实例化时,只分配类中的变量空间,并没有为函数分配空间。自从类的函数定义完成后,它就在那儿,不会跑的。
this指针是如何访问类中的变量的
在C++中,类和结构是只有一个区别的:类的成员默认是private,而结构是public。this是类的指针,如果换成结构体,那this就是结构的指针了。
我们只有获得一个对象后,才能通过对象使用this指针。如果我们知道一个对象this指针的位置,可以直接使用吗
this指针只有在成员函数中才有定义。因此,你获得一个对象后,也不能通过对象使用this指针。所以,我们无法知道一个对象的this指针的位置(只有在成员函数里才有this指针的位置)。当然,在成员函数里,你是可以知道this指针的位置的(可以通过&this获得),也可以直接使用它。
每个类编译后,是否创建一个类中函数表保存函数指针,以便用来调用函数
普通的类函数(不论是成员函数,还是静态函数)都不会创建一个函数表来保存函数指针。只有虚函数才会被放到函数表中。但是,即使是虚函数,如果编译期就能明确知道调用的是哪个函数,编译器就不会通过函数表中的指针来间接调用,而是会直接调用该函数。正是由于this指针的存在,用来指向不同的对象,从而确保不同对象之间调用相同的函数可以互不干扰。
145. 145. 145. 内存泄漏的后果?如何监测?解决方法
- 内存泄漏
内存泄漏是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制。
2) 后果
只发生一次小的内存泄漏可能不被注意,但泄漏大量内存的程序将会出现各种证照:性能下降到内存逐渐用完,导致另一个程序失败。
3) 解决方法
智能指针。
146. 146. 146. 在成员函数中调用delete this会出现什么问题?对象还可以使用吗
在类对象的内存空间中,只有数据成员和虚函数表指针,并不包含代码内容,类的成员函数单独放在代码段中。在调用成员函数时,隐含传递一个this指针,让成员函数知道当前是哪个对象在调用它。当调用delete this时,类对象的内存空间被释放。在delete this之后进行的其他任何函数调用,只要不涉及到this指针的内容,都能够正常运行。一旦涉及到this指针,如操作数据成员,调用虚函数等,就会出现不可预期的问题。
147. 147. 147. 为什么是不可预期的问题
delete this之后不是释放了类对象的内存空间了么,那么这段内存应该已经还给系统,不再属于这个进程。照这个逻辑来看,应该发生指针错误,无访问权限之类的令系统崩溃的问题才对啊?delete this释放了类对象的内存空间,但是内存空间却并不是马上被回收到系统中,可能是缓冲或者其他什么原因,导致这段内存空间暂时并没有被系统收回。此时这段内存是可以访问的,但是其中的值却是不确定的。当你获取数据成员,可能得到的是一串很长的未初始化的随机数;访问虚函数表,指针无效的可能性非常高,造成系统崩溃。
148. 148. 148. 如果在类的析构函数中调用delete this,会发生什么
会导致堆栈溢出。原因很简单,delete的本质是“为将被释放的内存调用一个或多个析构函数,然后,释放内存”。显然,delete this会去调用本对象的析构函数,而析构函数中又调用delete this,形成无限递归,造成堆栈溢出,系统崩溃。
149. 149. 149. 你知道空类的大小是多少吗
- C++空类的大小不为0,不同编译器设置不一样,一般为1;
- C++标准指出,不允许一个对象(当然包括类对象)的大小为0,不同的对象不能具有相同的地址;
- 带有虚函数的C++类大小不为1,因为每一个对象会有一个vptr指向虚函数表,具体大小根据指针大小确定;
- C++中要求对于类的每个实例都必须有独一无二的地址,那么编译器自动为空类分配一个字节大小,这样便保证了每个实例均有独一无二的内存地址。
150. 150. 150. this指针调用成员变量时,堆栈会发生什么变化
当在类的非静态成员函数访问类的非静态成员时,编译器会自动将对象的地址传给作为隐含参数传递给函数,这个隐含参数就是this指针。
即使你并没有写this指针,编译器在链接时也会加上this的,对各成员的访问都是通过this指针来进行的。
例如你建立了类的多个对象时,在调用类的成员函数时,你并不知道具体是哪个对象在调用,此时你可以通过查看this指针来查看具体是哪个对象在调用。This指针首先入栈,然后成员函数的参数从右向左进行入栈,最后函数返回地址入栈。
151. 151. 151. 类对象的大小受哪些因素影响
- 类的非静态成员变量大小,静态成员不占据类的空间,成员函数也不占据类的空间大小;
- 内存对齐另外分配的空间大小,类内的数据也是需要进行内存对齐操作的;
- 虚函数的话,会在类对象插入vptr指针,加上指针大小;
- 当该该类是某类的派生类,那么派生类继承的基类部分的数据成员也会存在在派生类中的空间中,也会对派生类进行扩展。
152. 152. 152. C++ 11有哪些新特性
- nullptr替代 NULL
- 引入了 auto 和 decltype 这两个关键字实现了类型推导
- 基于范围的 for 循环for(auto& i : res){}
- 类和结构体的中初始化列表
- Lambda 表达式(匿名函数)
- std::forward_list(单向链表)
- 右值引用和move语义
153. 153. 153. auto、decltype和decltype(auto)的用法
auto
C++11新标准引入了auto类型说明符,用它就能让编译器替我们去分析表达式所属的类型。和原来那些只对应某种特定的类型说明符(例如 int)不同。
auto 让编译器通过初始值来进行类型推演。从而获得定义变量的类型,所以说 auto 定义的变量必须有初始值。
//普通;类型
int a = 1, b = 3;
auto c = a + b;// c为int型
//const类型
const int i = 5;
auto j = i; // 变量i是顶层const, 会被忽略, 所以j的类型是int
auto k = &i; // 变量i是一个常量, 对常量取地址是一种底层const, 所以b的类型是const int*
const auto l = i; //如果希望推断出的类型是顶层const的, 那么就需要在auto前面加上cosnt
//引用和指针类型
int x = 2;
int& y = x;
auto z = y; //z是int型不是int& 型
auto& p1 = y; //p1是int&型
auto p2 = &x; //p2是指针类型int*
decltype
有的时候我们还会遇到这种情况,我们希望从表达式中推断出要定义变量的类型,但却不想用表达式的值去初始化变量。还有可能是函数的返回类型为某表达式的值类型。在这些时候auto显得就无力了,所以C++11又引入了第二种类型说明符decltype,它的作用是选择并返回操作数的数据类型。在此过程中,编译器只是分析表达式并得到它的类型,却不进行实际的计算表达式的值。
int func() {return 0};
//普通类型
decltype(func()) sum = 5; // sum的类型是函数func()的返回值的类型int, 但是这时不会实际调用func
int a = 0;
decltype(a) b = 4; // a的类型是int, 所以b的类型也是int
//不论是顶层const还是底层const, decltype都会保留
const int c = 3;
decltype(c) d = c; // d的类型和c是一样的, 都是顶层const
int e = 4;
const int* f = &e; // f是底层const
decltype(f) g = f; // g也是底层const
//引用与指针类型
//1. 如果表达式是引用类型, 那么decltype的类型也是引用
const int i = 3, &j = i;
decltype(j) k = 5; // k的类型是 const int&
//2. 如果表达式是引用类型, 但是想要得到这个引用所指向的类型, 需要修改表达式:
int i = 3, &r = i;
decltype(r + 0) t = 5; // 此时是int类型
//3. 对指针的解引用操作返回的是引用类型
int i = 3, j = 6, *p = &i;
decltype(*p) c = j; // c是int&类型, c和j绑定在一起
//4. 如果一个表达式的类型不是引用, 但是我们需要推断出引用, 那么可以加上一对括号, 就变成了引用类型了
int i = 3;
decltype((i)) j = i; // 此时j的类型是int&类型, j和i绑定在了一起
decltype(auto)
decltype(auto)是C++14新增的类型指示符,可以用来声明变量以及指示函数返回类型。在使用时,会将“=”号左边的表达式替换掉auto,再根据decltype的语法规则来确定类型,用于声明变量时,该变量会立即初始化。
int e = 4;
const int* f = &e; // f是底层const
decltype(auto) j = f;//j的类型是const int* 并且指向的是e
154. 154. 154. C++中NULL和nullptr区别
算是为了与C语言进行兼容而定义的一个问题吧。
NULL来自C语言,一般由宏定义实现,而 nullptr 则是C++11的新增关键字。在C语言中,NULL被定义为(void*)0,而在C++语言中,NULL则被定义为整数0。编译器一般对其实际定义如下:
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
在C++中指针必须有明确的类型定义。但是将NULL定义为0带来的另一个问题是无法与整数的0区分。因为C++中允许有函数重载,所以可以试想如下函数定义情况:
void fun(char* p) {
cout << "char*" << endl;
}
void fun(int p) {
cout << "int" << endl;
}
int main()
{
fun(NULL);
return 0;
}
//二义性错误
那么在传入NULL参数时,两个函数都匹配,从而出现了二义性错误。nullptr在C++11被引入用于解决这一问题,nullptr是一个明确的指针类型,能够根据环境自动转换成相应的指针类型,但不会被转换为任何整型,所以不会造成参数传递错误,但是如果有多个指针形参的函数,则同样也会出现二义性错误。
155. 155. 155. 智能指针的原理、常用的智能指针及实现
原理
智能指针是一个类,用来存储指向动态分配对象的指针,负责自动释放动态分配的对象,防止堆内存泄漏。动态分配的资源,交给一个类对象去管理,当类对象声明周期结束时,自动调用析构函数释放资源。
常用的智能指针
(1)shared_ptr
实现原理:采用引用计数器的方法,允许多个智能指针指向同一个对象,每当多一个指针指向该对象时,指向该对象的所有智能指针内部的引用计数加1,每当减少一个智能指针指向对象时,引用计数会减1,当计数为0的时候会自动的释放动态分配的资源。
1、智能指针将一个计数器与类指向的对象相关联,引用计数器跟踪共有多少个类对象共享同一指针。
2、每次创建类的新对象时,初始化指针并将引用计数置为1。
3、当对象作为另一对象的副本而创建时,拷贝构造函数拷贝指针并增加与之相应的引用计数。
4、对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数(如果引用计数为减至0,则删除对象),并增加右操作数所指对象的引用计数。
5、调用析构函数时,构造函数减少引用计数(如果引用计数减至0,则删除对象)。
(2)unique_ptr
unique_ptr采用的是独享所有权语义,一个非空的unique_ptr总是拥有它所指向的资源。转移一个unique_ptr将会把所有权全部从源指针转移给目标指针,源指针被置空;unique_ptr不支持普通的拷贝和赋值操作,不能用在STL标准容器中;局部变量的返回值除外(因为编译器知道要返回的对象将要被销毁);如果你拷贝一个unique_ptr,那么拷贝结束后,这两个unique_ptr都会指向相同的资源,造成在结束时对同一内存指针多次释放而导致程序崩溃。
(3)weak_ptr
weak_ptr弱引用。 引用计数有一个问题就是互相引用形成环(环形引用),这样两个指针指向的内存都无法释放。需要使用weak_ptr打破环形引用。weak_ptr是一个弱引用,它是为了配合shared_ptr而引入的一种智能指针,它指向一个由shared_ptr管理的对象而不影响所指对象的生命周期,也就是说,它只引用,不计数。如果一块内存被shared_ptr和weak_ptr同时引用,当所有shared_ptr析构了之后,不管还有没有weak_ptr引用该内存,内存也会被释放。所以weak_ptr不保证它指向的内存一定是有效的,在使用之前使用函数lock()检查weak_ptr是否为空指针。
(4)auto_ptr
主要是为了解决“有异常抛出时发生内存泄漏”的问题 。因为发生异常而无法正常释放内存。
auto_ptr有拷贝语义,拷贝后源对象变得无效,这可能引发很严重的问题;而unique_ptr则无拷贝语义,但提供了移动语义,这样的错误不再可能发生,因为很明显必须使用std::move()进行转移。
auto_ptr不支持拷贝和赋值操作,不能用在STL标准容器中。STL容器中的元素经常要支持拷贝、赋值操作,在这过程中auto_ptr会传递所有权,所以不能在STL中使用。
template<typename T>
class Shared_Ptr{
private:
int *_count;
T *_ptr;
public:
explicit Shared_Ptr(T* ptr = nullptr) : _ptr(ptr), _count(new int(1)) {}
Shared_Ptr(const Shared_Ptr<T>& rhs) : _ptr(rhs._ptr), _count(rhs._count) {
++*_count;
}
Shared_Ptr<T>& operator=(const Shared_Ptr<T>& rhs) {
if(this == &rhs) return *this;
if(_ptr && --*_count == 0) {
delete _ptr;
delete _count;
_ptr = nullptr;
_count = nullptr;
}
_ptr = rhs._ptr;
_count = rhs._count;
++*_count;
return *this;
}
int use_count() { return *_count; }
T* operator->() { return _ptr; }
T& operator*() { return *_ptr; }
~Shared_Ptr() {
if(_ptr && --*_count == 0) {
delete _ptr;
delete _count;
_ptr = nullptr;
_count = nullptr;
}
}
};
156. 156. 156. 说一说你了解的关于lambda函数的全部知识
-
利用lambda表达式可以编写内嵌的匿名函数,用以替换独立函数或者函数对象;
-
每当你定义一个lambda表达式后,编译器会自动生成一个匿名类(这个类当然重载了()运算符),我们称为闭包类型(closure type)。那么在运行时,这个lambda表达式就会返回一个匿名的闭包实例,是一个右值。所以,我们上面的lambda表达式的结果就是一个个闭包。闭包的一个强大之处是其可以通过传值或者引用的方式捕捉其封装作用域内的变量,前面的方括号就是用来定义捕捉模式以及变量,我们又将其称为lambda捕捉块。
-
lambda表达式的语法定义如下:
[capture] (parameters) mutable ->return-type {statement};
- lambda必须使用尾置返回来指定返回类型,可以忽略参数列表和返回值,但必须永远包含捕获列表和函数体。
157. 157. 157. 智能指针的作用
- C++11中引入了智能指针的概念,方便管理堆内存。使用普通指针,容易造成堆内存泄露(忘记释放),二次释放,程序发生异常时内存泄露等问题等,使用智能指针能更好的管理堆内存。
- 智能指针在C++11版本之后提供,包含在头文件
<memory>
中,shared_ptr、unique_ptr、weak_ptr。shared_ptr多个指针指向相同的对象。shared_ptr使用引用计数,每一个shared_ptr的拷贝都指向相同的内存。每使用他一次,内部的引用计数加1,每析构一次,内部的引用计数减1,减为0时,自动删除所指向的堆内存。shared_ptr内部的引用计数是线程安全的,但是对象的读取需要加锁。 - 初始化。智能指针是个模板类,可以指定类型,传入指针通过构造函数初始化。也可以使用make_shared函数初始化。不能将指针直接赋值给一个智能指针,一个是类,一个是指针。例如
std::shared_ptr<int> p4 = new int(1);
的写法是错误的。拷贝使得对象的引用计数增加1,赋值使得原对象引用计数减1,当计数为0时,自动释放内存。后来指向的对象引用计数加1。 - unique_ptr“唯一”拥有其所指对象,同一时刻只能有一个unique_ptr指向给定对象(通过禁止拷贝语义、只有移动语义来实现)。相比于原始指针,unique_ptr使得在出现异常的情况下,动态资源能得到释放。unique_ptr指针本身的生命周期:从unique_ptr指针创建时开始,直到离开作用域。离开作用域时,若其指向对象,则将其所指对象销毁(默认使用delete操作符,用户可指定其他操作)。unique_ptr指针与其所指对象的关系:在智能指针生命周期内,可以改变智能指针所指对象,如创建智能指针时通过构造函数指定、通过reset方法重新指定、通过release方法释放所有权、通过移动语义转移所有权。
- 智能指针类将一个计数器与类指向的对象相关联,引用计数跟踪该类有多少个对象共享同一指针。每次创建类的新对象时,初始化指针并将引用计数置为1;当对象作为另一对象的副本而创建时,拷贝构造函数拷贝指针并增加与之相应的引用计数;对一个对象进行赋值时,赋值操作符减少左操作数所指对象的引用计数(如果引用计数为减至0,则删除对象),并增加右操作数所指对象的引用计数;调用析构函数时,构造函数减少引用计数(如果引用计数减至0,则删除基础对象)。
- weak_ptr 是一种不控制对象生命周期的智能指针, 它指向一个 shared_ptr 管理的对象. 进行该对象的内存管理的是那个强引用的 shared_ptr. weak_ptr只是提供了对管理对象的一个访问手段。weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作, 它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少。
159. 159. 159. 智能指针出现循环引用怎么解决
弱指针用于专门解决shared_ptr循环引用的问题,weak_ptr不会修改引用计数,即其存在与否并不影响对象的引用计数器。循环引用就是:两个对象互相使用一个shared_ptr成员变量指向对方。弱引用并不对对象的内存进行管理,在功能上类似于普通指针,然而一个比较大的区别是,弱引用能检测到所管理的对象是否已经被释放,从而避免访问非法内存。
160. 160. 160. 什么是STL
C++ STL从广义来讲包括了三类:算法,容器和迭代器。
1、算法包括排序,复制等常用算法,以及不同容器特定的算法。
2、容器就是数据的存放形式,包括序列式容器和关联式容器,序列式容器就是list,vector等,关联式容器就是set,map等。
3、迭代器就是在不暴露容器内部结构的情况下对容器的遍历。
161. 161. 161. 解释一下什么是trivial destructor
“trivial destructor”一般是指用户没有自定义析构函数,而由系统生成的。
反之,用户自定义了析构函数,则称之为“non-trivial destructor”,这种析构函数如果申请了新的空间一定要显式的释放,否则会造成内存泄露。
对于trivial destructor,如果每次都进行调用,显然对效率是一种伤害,如何进行判断呢?
首先利用value_type()获取所指对象的型别,再利用__type_traits判断该型别的析构函数是否non-trivial,若是(__true_type),则什么也不做,若为(__false_type),则去调用destory()函数。
在实际的应用当中,STL库提供了相关的判断方法__type_traits。除了trivial destructor,还有trivial construct、trivial copy construct等,如果能够对是否trivial进行区分,可以采用内存处理函数memcpy()、malloc()等更加高效的完成相关操作,提升效率。
162. 162. 162. 使用智能指针管理内存资源,RAII是怎么回事
- RAII直译过来是“资源获取即初始化”,也就是说在构造函数中申请分配资源,在析构函数中释放资源。因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。
- 智能指针(std::shared_ptr和std::unique_ptr)即RAII最具代表的实现,使用智能指针,可以实现自动的内存管理,再也不需要担心忘记delete造成的内存泄漏。
163. 163. 163. 迭代器:++it、it++哪个好,为什么
- 前置返回一个引用,后置返回一个对象。
- 前置不会产生临时对象,后置必须产生临时对象,临时对象会导致效率降低。
- 实际上在编译器优化的情况下,两种写法没有太大的效率差别。
164. 164. 164. 说一下C++左值引用和右值引用
C++11正是通过引入右值引用来优化性能,具体来说是通过移动语义来避免无谓拷贝的问题,通过move语义来将临时生成的左值中的资源无代价的转移到另外一个对象中去,通过完美转发来解决不能按照参数实际类型来转发的问题(同时,完美转发获得的一个好处是可以实现移动语义)。
- 在C++11中所有的值必属于左值、右值两者之一,右值又可以细分为纯右值、将亡值。在C++11中可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值(将亡值或纯右值)。举个例子,int a = b+c, a 就是左值,其有变量名为a,通过&a可以获取该变量的地址;表达式b+c、函数int func()的返回值是右值,在其被赋值给某一变量前,我们不能通过变量名找到它,&(b+c)这样的操作则不会通过编译
- C++11对C++98中的右值进行了扩充。在C++11中右值又分为纯右值和将亡值。其中纯右值指的是临时变量和不跟对象关联的字面量值;将亡值则是C++11新增的跟右值引用相关的表达式,这样表达式通常是将要被移动的对象,比如返回右值引用T&&的函数返回值、std::move的返回值,或者转换为T&&的类型转换函数的返回值。将亡值可以理解为通过“盗取”其他变量内存空间的方式获取到的值。在确保其他变量不再被使用、或即将被销毁时,通过“盗取”的方式可以避免内存空间的释放和分配,能够延长变量值的生命期。
- 左值引用就是对一个左值进行引用的类型。右值引用就是对一个右值进行引用的类型,由于右值通常不具有名字,我们也只能通过引用的方式找到它的存在。右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。因为引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。左值引用是有具体名字的变量的别名,而右值引用则是不具名字的变量的别名。左值引用通常也不能绑定到右值,但常量左值引用是个“万能”的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。不过常量左值所引用的右值在它的“余生”中只能是只读的。相对地,非常量左值只能接受非常量左值对其进行初始化。
- 右值引用通常不能绑定到任何的左值,要想绑定一个左值到右值引用,通常需要std::move()将左值强制转换为右值。
165. 165. 165. 左值和右值
左值:表示的是可以获取地址的表达式,它能出现在赋值语句的左边,对该表达式进行赋值。
右值:表示无法获取地址的对象,有常量值、函数返回值、lambda表达式等。无法获取地址,但不表示其不可改变,当定义了右值的右值引用时就可以更改右值。
166. 166. 166. 右值引用的特点
右值引用:C++11中增加了右值引用,右值引用关联到右值时,右值被存储到特定位置,右值引用指向该特定位置,右值虽然无法获取地址,但是右值引用是可以获取地址的,该地址表示临时对象的存储位置。
特点1:通过右值引用的声明,右值又“重获新生”,其生命周期与右值引用类型变量的生命周期一样长,只要该变量还活着,该右值临时量将会一直存活下去。
特点2:右值引用独立于左值和右值。意思是右值引用类型的变量可能是左值也可能是右值
特点3:T&& t在发生自动类型推断的时候,它是左值还是右值取决于它的初始化。
167. 167. 167. STL中hashtable的实现
STL中的hashtable使用的是开链法解决hash冲突问题:
hashtable中的bucket所维护的list是自己定义的链表,而bucket聚合体本身使用vector进行存储。hashtable的迭代器只提供前进操作,不提供后退操作。
在hashtable设计bucket的数量上,其内置了28个质数[53, 97, 193,…,429496729],在创建hashtable时,会根据存入的元素个数选择大于等于元素个数的质数作为hashtable的容量(vector的长度),其中每个bucket所维护的linked-list长度也等于hashtable的容量。如果插入hashtable的元素个数超过了bucket的容量,就要进行重建table操作,即找出下一个质数,创建新的buckets vector,重新计算元素在新hashtable的位置。
168. 168. 168. 简单说一下traits技法
traits技法利用“内嵌型别“的编程技巧与编译器的template参数推导功能,增强C++未能提供的关于型别认证方面的能力。常用的有iterator_traits和type_traits。
iterator_traits
被称为特性萃取机,能够方面的让外界获取以下5种型别:
1、value_type:迭代器所指对象的型别
2、difference_type:两个迭代器之间的距离
3、pointer:迭代器所指向的型别
4、reference:迭代器所引用的型别
5、iterator_category:迭代器的分类,只读、只写、单向、双向、随机。
type_traits
关注的是型别的特性,例如这个型别是否具备自定义的构造函数、拷贝构造函数、赋值运算符和析构函数,如果答案是否定的,可以采取直接操作内存的方式提高效率,一般来说,type_traits支持以下5中类型的判断:
__type_traits<T>::has_trivial_default_constructor
__type_traits<T>::has_trivial_copy_constructor
__type_traits<T>::has_trivial_assignment_operator
__type_traits<T>::has_trivial_destructor
__type_traits<T>::is_POD_type
由于编译器只针对class object形式的参数进行参数推导,因此上式 的返回结果不应该是个bool值,实际上使用的是一种空的结构体:
struct __true_type{};struct __false_type{};
这两个结构体没有任何成员,不会带来其他的负担,又能满足需求,可谓一举两得。当然,如果我们自行定义了一个Shape类型,也可以针对这个Shape设计type_traits的特化版本:
template<> struct __type_traits<Shape>{
typedef __true_type has_trivial_default_constructor;
typedef __false_type has_trivial_copy_constructor;
typedef __false_type has_trivial_assignment_operator;
typedef __false_type has_trivial_destructor;
typedef __false_type is_POD_type;
};
169. 169. 169. STL 中vector删除其中的元素,迭代器如何变化?为什么是两倍扩容?释放空间
erase迭代器不仅使所指向被删除的迭代器失效,而且使被删元素之后的所有迭代器失效(list除外),所以不能使用erase(it++)的方式,但是erase的返回值是下一个有效迭代器;It = c.erase(it)。
size()函数返回的是已用空间大小,capacity()返回的是总空间大小,capacity() - size()则是剩余的可用空间大小。当size()和capacity()相等,说明vector目前的空间已被用完,如果再添加新元素,则会引起vector空间的动态增长。
由于动态增长会引起重新分配内存空间、拷贝原空间、释放原空间,这些过程会降低程序效率。因此,可以使用reserve(n)预先分配一块较大的指定大小的内存空间,这样当指定大小的内存空间未使用完时,是不会重新分配内存空间的,这样便提升了效率。只有当n>capacity()时,调用reserve(n)才会改变vector容量。
resize()成员函数只改变元素的数目,不改变vector的容量。
1、空的vector对象,size()和capacity()都为0。
2、当空间大小不足时,新分配的空间大小为原空间大小的2倍。
3、使用reserve()预先分配一块内存后,在空间未满的情况下,不会引起重新分配,从而提升了效率。
4、当reserve()分配的空间比原空间小时,是不会引起重新分配的。
5、resize()函数只改变容器的元素数目,未改变容器大小。
6、用reserve(size_type)只是扩大capacity值,这些内存空间可能还是“野”的,如果此时使用“[ ]”来访问,则可能会越界。而resize(size_type new_size)会真正使容器具有new_size个对象。
不同的编译器,vector有不同的扩容大小。在vs下是1.5倍,在GCC下是2倍。
空间和时间的权衡。简单来说, 空间分配的多,平摊时间复杂度低,但浪费空间也多。
使用k=2增长因子的问题在于,每次扩展的新尺寸必然刚好大于之前分配的总和,也就是说,之前分配的内存空间不可能被使用。这样对内存不友好。最好把增长因子设为(1,2)。
对比可以发现采用成倍方式扩容,可以保证常数的时间复杂度,而增加指定大小的容量只能达到O(n)的时间复杂度,因此,使用成倍的方式扩容。
170. 170. 170. Vector如何释放空间
由于vector的内存占用空间只增不减,比如你首先分配了10,000个元素,然后erase掉后面9,999个,留下一个有效元素,但是内存占用仍为10,000个。所有内存空间是在vector析构时候才能被系统回收。empty()用来检测容器是否为空的,clear()可以清空所有元素。但是即使clear(),vector所占用的内存空间不会释放,无法保证内存的回收。
如果需要空间动态缩小,可以考虑使用deque。如果vector,可以用swap()来帮助你释放内存。
vector(Vec).swap(Vec); //将Vec的内存清除
vector().swap(Vec); //清空Vec的内存
v.shrink_to_fit();//请求回收为使用的容量,只是请求,如果回收,则迭代器和引用失效
171. 171. 171. 容器内部删除一个元素
- 顺序容器(序列式容器,比如vector、deque)
erase迭代器不仅使所指向被删除的迭代器失效,而且使被删元素之后的所有迭代器失效(list除外),所以不能使用erase(it++)的方式,但是erase的返回值是下一个有效迭代器;It = c.erase(it)。
- 关联容器(关联式容器,比如map、set、multimap、multiset等
erase迭代器只是被删除元素的迭代器失效,但是返回值是void,所以要采用erase(it++)的方式删除迭代器;c.erase(it++)。
172. 172. 172. STL迭代器如何实现
1、 迭代器是一种抽象的设计理念,通过迭代器可以在不了解容器内部原理的情况下遍历容器,除此之外,STL中迭代器一个最重要的作用就是作为容器与STL算法的粘合剂。
2、 迭代器的作用就是提供一个遍历容器内部所有元素的接口,因此迭代器内部必须保存一个与容器相关联的指针,然后重载各种运算操作来遍历,其中最重要的是*
运算符与->
运算符,以及++、--
等可能需要重载的运算符重载。这和C++中的智能指针很像,智能指针也是将一个指针封装,然后通过引用计数或是其他方法完成自动释放内存的功能。
3、最常用的迭代器的相应型别有五种:value type、difference type、pointer、reference、iterator catagoly。
173. 173. 173. map、set是怎么实现的,红黑树是怎么能够同时实现这两种容器? 为什么使用红黑树
- 他们的底层都是以红黑树的结构实现,因此插入删除等操作都在O(logn)时间内完成,因此可以完成高效的插入删除。
- 红黑树是具有模板参数的,实现map的红黑树的节点数据类型是key+value,而实现set的节点数据类型是value。
- 因为map和set要求是自动排序的,红黑树能够实现这一功能,而且时间复杂度比较低。
- AVL是严格平衡的,频繁的插入和删除,会引起频繁的旋转操作,导致效率降低;红黑树是弱平衡的,算是一种折中,插入最多旋转2次,删除最多旋转3次。所以红黑树在查找、插入删除的复杂度都是O(logn),且性能稳定。
174. 174. 174. 如何在共享内存上使用STL标准库
-
想像一下把STL容器,例如map, vector, list等等,放入共享内存中,IPC一旦有了这些强大的通用数据结构做辅助,无疑进程间通信的能力一下子强大了很多。我们没必要再为共享内存设计其他额外的数据结构,另外,STL的高度可扩展性将为IPC所驱使。STL容器被良好的封装,默认情况下有它们自己的内存管理方案。当一个元素被放入到一个STL容器中时,列表容器自动为其分配内存,保存数据。考虑到要将STL容器放到共享内存中,而容器却自己在堆上分配内存。一个最笨拙的办法是在堆上构造STL容器,然后把容器复制到共享内存,并且确保所有容器的内部分配的内存指向共享内存中的相应区域,这基本是个不可能完成的任务。
-
假设进程A在共享内存中放入了数个容器,进程B如何找到这些容器呢?一个方法就是进程A把容器放在共享内存中的确定地址上,则进程B可以从该已知地址上获取容器。另外一个改进点的办法是,进程A先在共享内存某块确定地址上放置一个map容器,然后进程A再创建其他容器,然后给其取个名字和地址一并保存到这个map容器里。进程B知道如何获取该保存了地址映射的map容器,然后同样再根据名字取得其他容器的地址。
175. 175. 175. map插入方式有哪几种
- 用insert函数插入pair数据,
mapStudent.insert(pair<int, string>(1, "student_one"));
- 用insert函数插入value_type数据
mapStudent.insert(map<int, string>::value_type (1, "student_one"));
- 在insert函数中使用make_pair()函数
mapStudent.insert(make_pair(1, "student_one"));
- 用数组方式插入数据
mapStudent[1] = "student_one";
176. 176. 176. STL中unordered_map(hash_map)和map的区别,hash_map如何解决冲突以及扩容
- unordered_map和map类似,都是存储的key-value的值,可以通过key快速索引到value。不同的是unordered_map不会根据key的大小进行排序。
- unordered_map内部元素是无序的,存储时是根据key的hash值判断元素是否相同,而map中的元素是按照二叉搜索树存储,进行中序遍历会得到有序遍历。
- 所以使用时map的key需要定义operator<。而unordered_map需要定义hash_value函数并且重载
operator==
。但是很多系统内置的数据类型都自带这些, - 那么如果是自定义类型,那么就需要自己重载operator<或者hash_value()了。
- 如果需要内部元素自动排序,使用map,不需要排序使用unordered_map
- unordered_map的底层实现是hash_table;
- hash_map底层使用的是hash_table,而hash_table使用的开链法进行冲突避免,所有hash_map采用开链法进行冲突解决。
- 什么时候扩容:当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值,即当前数组的长度乘以加载因子的值的时候,就要自动扩容。
- 扩容就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。
177. 177. 177. vector越界访问下标,map越界访问下标?vector删除元素时会不会释放空间
- 通过下标访问vector中的元素时不会做边界检查,如果越界则会发生未定义行为,返回一个无意义的值,而通过at返回vector的元素,则会进行边界检查,如果越界会抛出异常。
- map的下标运算符[]的作用是:将key作为下标去执行查找,并返回相应的值;如果不存在这个key,就将一个具有该key和value的默认值插入这个map。
- erase()函数,只能删除内容,不能改变容量大小。使用erase时,它删除了迭代器指向的元素,并且返回要被删除的之后的下一个有效迭代器,迭代器相当于一个智能指针。clear()函数,只能清空内容,不能改变容量大小;如果要想在删除内容的同时释放内存,那么你可以选择deque容器。
178. 178. 178. map中[]与find的区别
- map的下标运算符[]的作用是:将key作为下标去执行查找,并返回对应的值;如果不存在这个key,就将一个具有key和value的默认值的项插入这个map。
- map的find函数:用key执行查找,找到了返回该位置的迭代器;如果不存在这个关键码,就返回尾迭代器。
179. 179. 179. STL中list与deque之间的区别
- list不再能够像vector一样以普通指针作为迭代器,因为list不保证在存储空间中连续存在。
- list插入操作不会造成原有的list迭代器失效,删除操作只会使得指向删除元素的迭代器失效。
- list不仅是一个双向链表,而且还是一个环状双向链表,所以它只需要一个指针;
- list不像vector那样有可能在空间不足时做重新配置、数据移动的操作,所以插入前的所有迭代器在插入操作之后都仍然有效;
- deque是一种双向开口的连续线性空间,它的连续只是表面上是连续的。所谓双向开口,意思是可以在头尾两端分别做元素的插入和删除操作。
- deque和vector最大的差异,一在于deque允许常数时间内对起头端进行元素的插入或移除操作,二在于deque没有所谓容量概念,因为它是动态地以分段连续空间组合而成,随时可以增加一段新的空间并链接起来,deque没有所谓的空间保留功能。
180. 180. 180. STL中的allocator、deallocator
- 第一级配置器直接使用malloc()、free()和relloc(),第二级配置器视情况采用不同的策略:当配置区块超过128bytes时,视之为足够大,便调用第一级配置器;当配置器区块小于128bytes时,为了降低额外负担,使用复杂的内存池整理方式,而不再用一级配置器。
- 第二级配置器主动将任何小额区块的内存需求量上调至8的倍数,并维护16个free-list,各自管理大小为8~128bytes的小额区块。
- 空间配置函数allocate(),首先判断区块大小,大于128就直接调用第一级配置器,小于128时就检查对应的free-list。如果free-list之内有可用区块,就直接拿来用,如果没有可用区块,就将区块大小调整至8的倍数,然后调用refill(),为free-list重新分配空间。
- 空间释放函数deallocate(),该函数首先判断区块大小,大于128bytes时,直接调用一级配置器,小于128bytes就找到对应的free-list然后释放内存。
181. 181. 181. STL中hash_map扩容发生什么
- hashtable表格内的元素称为桶,而由桶所链接的元素称为节点,其中使用vector容器来保存桶元素,因为vector容器本身具有动态扩容能力,无需人工干预。
- 向前操作:首先尝试从目前所指的节点出发,前进一个位置(节点),如果目前正巧是list的尾端,就跳至下一个bucket身上,那正是指向下一个list的头部节点。
182. 182. 182. 常见容器性质总结
- vector 底层数据结构为数组 ,支持快速随机访问。
- list 底层数据结构为双向链表,支持快速增删。
- deque 底层数据结构为一个中央控制器和多个缓冲区,支持首尾(中间不能)快速增删,也支持随机访问。
- stack 底层一般用list或deque实现,封闭头部即可,不用vector的原因应该是容量大小有限制,扩容耗时。
- queue 底层一般用list或deque实现,封闭头部即可,不用vector的原因应该是容量大小有限制,扩容耗时。(stack和queue其实是适配器,而不叫容器,因为是对容器的再封装)
- priority_queue 的底层数据结构一般为vector为底层容器,堆heap为处理规则来管理底层容器实现。
- set 底层数据结构为红黑树,有序,不重复。
- multiset 底层数据结构为红黑树,有序,可重复。
- map 底层数据结构为红黑树,有序,不重复。
- multimap 底层数据结构为红黑树,有序,可重复。
- unordered_set 底层数据结构为hash表,无序,不重复。
- unordered_multiset 底层数据结构为hash表,无序,可重复。
- unordered_map 底层数据结构为hash表,无序,不重复。
- unordered_multimap 底层数据结构为hash表,无序,可重复。
183. 183. 183. vector的增加删除都是怎么做的?为什么是1.5或者是2倍
- vector通过一个连续的数组存放元素,如果集合已满,在新增数据的时候,就要分配一块更大的内存,将原来的数据复制过来,释放之前的内存,在插入新增的元素。
- 对vector的任何操作,一旦引起空间重新配置,指向原vector的所有迭代器就都失效了 。
- 初始时刻vector的capacity为0,塞入第一个元素后capacity增加为1。
- 不同的编译器实现的扩容方式不一样,VS2015中以1.5倍扩容,GCC以2倍扩容。对比可以发现采用成倍方式扩容,可以保证常数的时间复杂度,而增加指定大小的容量只能达到O(n)的时间复杂度,因此,使用成倍的方式扩容。
- 考虑可能产生的堆空间浪费,成倍增长倍数不能太大,使用较为广泛的扩容方式有两种,以二倍的方式扩容,或者以1.5倍的方式扩容。
- 以2倍的方式扩容,导致下一次申请的内存必然大于之前分配内存的总和,导致之前分配的内存不能再被使用。
- 向量容器vector的成员函数pop_back()可以删除最后一个元素。
- 而函数erase()可以删除由一个iterator指出的元素,也可以删除一个指定范围的元素。
- 还可以采用通用算法remove()来删除vector容器中的元素。
- 不同的是:采用remove一般情况下不会改变容器的大小,而pop_back()与erase()等成员函数会改变容器的大小。
184. 184. 184. 说一下STL每种容器对应的迭代器
185. 185. 185. STL中迭代器失效的情况有哪些
插入元素:
1、尾后插入:size < capacity时,首迭代器不失效尾迭代失效(未重新分配空间),size == capacity时,所有迭代器均失效(需要重新分配空间)。
2、中间插入:中间插入:size < capacity时,首迭代器不失效但插入元素之后所有迭代器失效,size ==capacity时,所有迭代器均失效。
删除元素:
1、尾后删除:只有尾迭代失效。
2、中间删除:删除位置之后所有迭代失效。
deque 和 vector 的情况类似。
而list双向链表每一个节点内存不连续, 删除节点仅当前迭代器失效,erase返回下一个有效迭代器。
map/set等关联容器底层是红黑树删除节点不会影响其他节点的迭代器, 使用递增方法获取下一个迭代器mmp.erase(iter++)。
unordered_(hash) 迭代器意义不大, rehash之后, 迭代器应该也是全部失效。
186. 186. 186. STL中vector的实现
vector是一种序列式容器,其数据安排以及操作方式与数组非常类似,两者的唯一差别就是对于空间运用的灵活性,数组占用的是静态空间,一旦配置了就不可以改变大小,如果遇到空间不足的情况还要自行创建更大的空间,并手动将数据拷贝到新的空间中,再把原来的空间释放。vector则使用灵活的动态空间配置,维护一块连续的线性空间,在空间不足时,可以自动扩展空间容纳新元素,做到按需供给。其在扩充空间的过程中需要重新配置空间,移动数据,释放原空间等操作。动态扩容的规则:以原大小的两倍或1.5倍配置另外一块较大的空间。动态扩容空间对程序性能有一定的影响,我们可以使用reserve指定分配的内存大小,只要内存还有空间,就不会重新分配空间。
187. 187. 187. STL中slist的实现
list是双向链表,而slist是单向链表,它们的主要区别在于:list的迭代器是双向的,而slist迭代器属于单向的。虽然slist的很多功能不如list灵活,但是其所耗用的空间更小,操作更快。
根据STL的习惯,插入操作会将新元素插入到指定位置之前,而非之后,然而slist只能往后走,因此在slist的其他位置插入或者移除元素是十分不明智的,但是在slist开头却是可取的,slist特别提供了insert_after()和erase_after供灵活应用。考虑到效率问题,slist只提供push_front()操作,元素插入到slist后,存储的次序和输入的次序是相反的。
C++标准委员会没有采用slist的名称,forward_list在C++ 11中出现,它与slist的区别是没有size()方法。
188. 188. 188. STL中list的实现
它的好处在于插入或删除都只作用于一个元素空间,对任何位置元素的插入和删除都是常数时间。list不能保证节点在存储空间中连续存储,拥有迭代器,迭代器的“++”、“–”操作对于的是指针的操作,list提供的迭代器类型是双向迭代器。
list是一个双向链表。list与vector的另一个区别是,在插入和接合操作之后,都不会造成原迭代器失效,而vector可能因为空间重新配置导致迭代器失效。
list也是一个环形链表,因此只要一个指针便能完整表现整个链表。list中node节点指针始终指向尾端的一个空白节点,因此是一种“前闭后开”的区间结构。
189. 189. 189. STL中的deque的实现
deque是一种双向开口的连续线性空间。
deque可以在常数时间内对头端进行元素操作,deque没有容量的概念,它是动态地以分段连续空间组合而成,可以随时增加一段新的空间并链接起来。
deque虽然也提供随机访问的迭代器,但是其迭代器并不是普通的指针,其复杂程度比vector高很多,因此除非必要,否则一般使用vector而非deque。如果需要对deque排序,可以先将deque中的元素复制到vector中,利用sort对vector排序,再将结果复制回deque。
deque由一段一段的定量连续空间组成,一旦需要增加新的空间,只要配置一段定量连续空间拼接在头部或尾部即可,因此deque的最大任务是如何维护这个整体的连续性。
190. 190. 190. STL中stack和queue的实现
stack和queue都算是一种容器配接器。
stack(栈)是一种先进后出(First In Last Out)的数据结构,除了获取栈顶元素外,没有其他方法可以获取到内部的其他元素。
stack这种单向开口的数据结构很容易由双向开口的deque和list形成,只需要根据stack的性质对应移除某些接口即可实现。
stack除了默认使用deque作为其底层容器之外,也可以使用双向开口的list,只需要在初始化stack时,将list作为第二个参数即可。由于stack只能操作顶端的元素,因此其内部元素无法被访问,也不提供迭代器。
queue(队列)是一种先进先出(First In First Out)的数据结构,除了获取队列头部元素外,没有其他方法可以获取到内部的其他元素。
queue这种“先进先出”的数据结构很容易由双向开口的deque和list形成,只需要根据queue的性质对应移除某些接口即可实现。
queue也可以使用list作为底层容器,不具有遍历功能,没有迭代器。
191. 191. 191. STL中的heap的实现
heap(堆)并不是STL的容器组件,是优先队列的底层实现机制,因为大根堆总是最大值位于堆的根部,优先级最高。
binary heap本质是一种完全二叉树,整棵binary tree除了最底层的叶节点之外,都是填满的,但是叶节点从左到右不会出现空隙。
完全二叉树内没有任何节点漏洞,是非常紧凑的,这样的一个好处是可以使用数组来存储所有的节点,因为当其中某个节点位于 i i i处,其左节点必定位于 2 i 2i 2i处,右节点位于 2 i + 1 2i+1 2i+1处,父节点位于 i / 2 i/2 i/2(向下取整)处。这种表示方式称为隐式表述法。
push_heap插入算法
由于完全二叉树的性质,新插入的元素一定是位于树的最底层作为叶子节点,并填补由左至右的第一个空格。事实上,在刚执行插入操作时,新元素位于底层vector的end()处,之后是一个称为percolateup(上溯)的过程。
pop_heap算法
heap的pop操作弹出的是根节点,在heap内部执行pop_heap时,只是将其移动到vector的最后位置,然后再为这个被挤走的元素找到一个合适的安放位置,使整颗树满足完全二叉树的条件。这个被挤掉的元素首先会与根结点的两个子节点比较,并与较大的子节点更换位置,如此一直往下,直到这个被挤掉的元素大于左右两个子节点,或者下放到叶节点为止,这个过程称为percolate down(下溯)。
sort算法
因为pop_heap可以将当前heap中的最大值置于底层容器vector的末尾,heap范围减1,那么不断的执行pop_heap直到树为空,即可得到一个递增序列。
make_heap算法
将一段数据转化为heap,一个一个数据插入。
192. 192. 192. STL中的priority_queue的实现
priority_queue,优先队列,是一个拥有权值观念的queue,在插入元素时,元素并非按照插入次序排列,它会自动根据权值排列,权值最高,排在最前面。
默认情况下,priority_queue使用一个max-heap完成,底层容器使用的是一般为vector为底层容器,堆heap为处理规则来管理底层容器实现 。priority_queue的这种实现机制导致其不被归为容器,而是一种容器配接器。
priority_queue的所有元素,进出都有一定的规则,只有queue顶端的元素(权值最高者)才有机会被外界取用,它没有遍历功能,也不提供迭代器。
193. 193. 193. STL中set的实现
STL中的容器可分为序列式容器(sequence)和关联式容器(associative),set属于关联式容器。
set的特性是,所有元素都会根据元素的值自动被排序(默认升序),set元素的键值就是实值,实值就是键值,set不允许有两个相同的键值。
set不允许迭代器修改元素的值。
标准的STL set以RB-tree(红黑树)作为底层机制,几乎所有的set操作行为都是转调用RB-tree的操作行为。
红黑树的特性:
1、每个节点不是红色就是黑色
2、根结点为黑色
3、如果节点为红色,其子节点必为黑
4、任一节点至(NULL)树尾端的任何路径,所含的黑节点数量必相同
关联式容器尽量使用其自身提供的find()函数查找指定的元素,效率更高,因为STL提供的find()函数是一种顺序搜索算法。
194. 194. 194. STL中map的实现
map的特性是所有元素会根据键值进行自动排序。map中所有的元素都是pair,拥有键值(key)和实值(value)两个部分,并且不允许元素有相同的key。
一旦map的key确定了,那么是无法修改的,但是可以修改这个key对应的value。
标准STL map的底层机制是RB-tree(红黑树)。
map的在构造时缺省采用递增排序key,也使用alloc配置器配置空间大小,需要注意的是在插入元素时,调用的是红黑树中的insert_unique()方法,而非insert_euqal()。
195. 195. 195. set和map的区别,multimap和multiset的区别
set只提供一种数据类型的接口,但是会将这一个元素分配到key和value上,这样就实现了set机制,set的key和value其实是一样的了。其实它保存的是两份元素,而不是只保存一份元素。
map则提供两种数据类型的接口,分别放在key和value的位置上,保存的确实是两份元素。
他们两个的insert都是采用红黑树的insert_unique() 。
multimap和map的唯一区别就是:multimap调用的是红黑树的insert_equal(),可以重复插入而map调用的则是独一无二的插入insert_unique(),multiset和set也一样,底层实现都是一样的,只是在插入的时候调用的方法不一样。
196. 196. 196. STL中unordered_map和map的区别和应用场景
map支持键值的自动排序,底层机制是红黑树,红黑树的查询和维护时间复杂度均为 O ( l o g n ) O(logn) O(logn),但是空间占用比较大,因为每个节点要保持父节点、孩子节点及颜色的信息。
unordered_map是C++ 11新添加的容器,底层机制是哈希表,通过hash函数计算元素位置,其查询时间复杂度为O(1),维护时间与bucket桶所维护的list长度有关,但是建立hash表耗时较大。
从两者的底层机制和特点可以看出:map适用于有序数据的应用场景,unordered_map适用于高效查询的应用场景。
197. 197. 197. hashtable中解决冲突有哪些方法
线性探测
使用hash函数计算出的位置如果已经有元素占用了,则向后依次寻找,找到表尾则回到表头,直到找到一个空位。
开链
每个表格维护一个list,如果hash函数计算出的值相同,则按顺序存在这个list中。
再散列
发生冲突时使用另一种hash函数再计算一个地址,直到不冲突。
二次探测
使用hash函数计算出的位置如果已经有元素占用了,按照 1 2 1^2 12、 2 2 2^2 22、 3 2 3^2 32…的步长依次寻找,如果步长是随机数序列,则称之为伪随机探测。
公共溢出区
一旦hash函数计算的结果相同,就放入公共溢出区。
198. 198. 198. C++的多态如何实现
C++多态是通过虚函数来实现的,在基类的函数前加上virtual关键字,在派生类中重写该函数,运行时将会根据所指对象的实际类型来调用相应的函数,如果对象类型是派生类,就调用派生类的函数,如果对象类型是基类,就调用基类的函数。
虚表:虚函数表的缩写,类中含有virtual关键字修饰的方法时,编译器会自动生成虚表。
虚表指针:在含有虚函数的类实例化对象时,对象地址就会有存储指向虚表的指针。
实现多态的过程:
(1)编译器在发现基类中有虚函数时,会自动为每个含有虚函数的类生成一份虚表,该表是一个一维数组,虚表里保存了虚函数的入口地址。
(2)编译器会在每个对象的前四个字节中保存一个虚表指针,即vptr,指向对象所属类的虚表。在构造时,根据对象的类型去初始化虚指针vptr,从而让vptr指向正确的虚表,从而在调用虚函数时,能找到正确的函数。
(3)在派生类定义对象时,程序运行会自动调用构造函数,在构造函数中创建虚表并对虚表初始化。在构造子类对象时,会先调用父类的构造函数,此时,编译器只“看到了”父类,并为父类对象初始化虚表指针,令它指向父类的虚表;当调用子类的构造函数时,为子类对象初始化虚表指针,令它指向子类的虚表
(4)当派生类对基类的虚函数没有重写时,派生类的虚表指针指向的是基类的虚表;当派生类对基类的虚函数重写时,派生类的虚表指针指向的是自身的虚表;当派生类中有自己的虚函数时,在自己的虚表中将此虚函数地址添加在后面,这样指向派生类的基类指针在运行时,就可以根据派生类对虚函数重写情况动态的进行调用,从而实现多态性。
199. 199. 199. 为什么析构函数一般写成虚函数
由于类的多态性,基类指针可以指向派生类的对象,如果删除该基类的指针,就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。
如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。
所以将析构函数声明为虚函数是十分必要的。在实现多态时,当用基类操作派生类,在析构时防止只析构基类而不析构派生类的状况发生,要将基类的析构函数声明为虚函数。
200. 200. 200. 构造函数能否声明为虚函数或者纯虚函数,析构函数呢
析构函数:
1、析构函数可以为虚函数,并且一般情况下基类析构函数要定义为虚函数。
2、只有在基类析构函数定义为虚函数时,调用操作符delete销毁指向对象的基类指针时,才能准确调用派生类的析构函数(从该级向上按序调用虚函数),才能准确销毁数据。
3、析构函数可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化。但派生类中可以根据自身需求重新改写基类中的纯虚函数。
构造函数:
1、构造函数不能定义为虚函数。在构造函数中可以调用虚函数,不过此时调用的是基类中的虚函数,而不是子类的虚函数,因为此时子类尚未构造好。
2、虚函数对应一个vtable(虚函数表),类中存储一个vptr指向这个vtable。如果构造函数是虚函数,就需要通过vtable调用,可是对象没有初始化就没有vptr,无法找到vtable,所以构造函数不能是虚函数。
201. 201. 201. 基类的虚函数表存放在内存的什么区,虚表指针vptr的初始化时间
虚函数表的特征:
1、虚函数表是全局共享的元素,即全局仅有一个,在编译时就构造完成。
2、虚函数表类似一个数组,类对象中存储vptr指针,指向虚函数表,即虚函数表不是函数,不是程序代码,不可能存储在代码段。
3、虚函数表存储虚函数的地址,即虚函数表的元素是指向类成员函数的指针,而类中虚函数的个数在编译时期可以确定,因此 虚函数表的大小是在编译时期确定的,不必动态分配内存空间存储虚函数表,所以不在堆中。
虚函数表vtable在Linux/Unix中存放在可执行文件的只读数据段中,而微软的编译器将虚函数表存放在常量段。
由于虚表指针vptr跟虚函数密不可分,对于有虚函数或者继承于拥有虚函数的基类,对该类进行实例化时,在构造函数执行时会对虚表指针进行初始化,并且存在对象内存布局的最前面。
C++中虚函数表位于只读数据段(.rodata),也就是C++内存模型中的常量区;而虚函数则位于代码段(.text),也就是C++内存模型中的代码区。
202. 202. 202. 模板函数和模板类的特例化
引入原因
编写单一的模板,它能适应多种类型的需求,使每种类型都具有相同的功能,但对于某种特定类型,如果要实现其特有的功能,单一模板就无法做到,这时就需要模板特例化。
定义
对单一模板提供的一个特殊实例,它将一个或多个模板参数绑定到特定的类型或值上。
(1)模板函数特例化
必须为原函数模板的每个模板参数都提供实参,且使用关键字template后跟一个空尖括号对<>,表明将原模板的所有模板参数提供实参。
本质
特例化的本质是实例化一个模板,而非重载它。特例化不影响参数匹配。参数匹配都以最佳匹配为原则。
注意
模板及其特例化版本应该声明在同一个头文件中,且所有同名模板的声明应该放在前面,后面放特例化版本。
(2)类模板特例化
类模板特例化和模板函数特例化相同。
(3)类模板的部分特例化
不必为所有模板参数提供实参,可以指定一部分而非所有模板参数,一个类模板的部分特例化本身仍是一个模板,使用它时还必须为其特例化版本中未指定的模板参数提供实参(特例化时类名一定要和原来的模板相同,只是参数类型不同,按最佳匹配原则,哪个最匹配,就用相应的模板)
(4)特例化类中的部分成员
可以特例化类中的部分成员函数而不是整个类。
203. 203. 203. 构造函数、析构函数、虚函数可否声明为内联函数
首先,将这些函数声明为内联函数,在语法上没有错误。因为inline同register一样,只是个建议,编译器并不一定真正的内联。
register关键字:这个关键字请求编译器尽可能的将变量存在CPU内部寄存器中,而不是通过内存寻址访问,以提高效率。
将构造函数和析构函数声明为inline是没有什么意义的,即编译器并不真正对声明为inline的构造和析构函数进行内联操作,因为编译器会在构造和析构函数中添加额外的操作(申请/释放内存,构造/析构对象等),所以构造函数/析构函数并不像看上去的那么精简。class中的函数默认是inline型的,编译器也只是有选择性的inline。
将虚函数声明为inline,要分情况讨论。
如果虚函数在就能够决定将要调用哪个函数时,就能够内联,否则由于虚函数是在运行期决定的,inline是在编译期决定的,此时无法内联。
因此,当是指向派生类的指针调用声明为inline的虚函数时,不会内联展开;当是对象本身调用虚函数时且函数不复杂的情况下,会内联展开。
204. 204. 204. C++模板是什么,你知道底层怎么实现的
- 编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。
- 因为函数模板要被实例化后才能成为真正的函数,在使用函数模板的文件中包含函数模板的头文件,如果该头文件中只有声明,没有定义,那编译器无法实例化该模板,最终导致链接错误。
205. 205. 205. 构造函数为什么不能为虚函数?析构函数为什么要虚函数
1、 **从存储空间的角度:**具有虚函数的类中会存在一个指向vtable虚函数表的指针,但是这个指向vtable的指针事实上是存储在对象的内存空间的。假设构造函数是虚的,就须要通过 vtable来调用,但是对象还没有实例化,内存空间还没有,找不到虚函数表,所以构造函数不能是虚函数。
2、 **从使用角度:**虚函数的作用在于通过父类的指针或者引用来调用函数的时候实现动态绑定,从而实现多态。而构造函数是在创建对象时自己主动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
3、**从实际含义上看:**在调用构造函数时还不能确定对象的真实类型(由于子类会调父类的构造函数);并且构造函数的作用是提供初始化,在对象生命期仅仅运行一次,不是对象的动态行为,也没有必要成为虚函数。
4、直接的讲,C++中基类采用virtual虚析构函数是为了防止内存泄漏。具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。所以,为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。
206. 206. 206. 析构函数的作用,如何起作用
析构函数与构造函数的作用相反,用于撤销对象的一些特殊任务处理,可以释放对象分配的内存空间。
特点:析构函数与构造函数同名,但该函数前面加~。析构函数没有参数,也没有返回值,而且不能重载,在一个类中只能有一个析构函数。 当对象的生命周期结束时,编译器会自动调用析构函数。每一个类必须有一个析构函数,用户可以自定义析构函数,也可以是编译器自动生成默认的析构函数。一般析构函数定义为类的公有成员。
207. 207. 207. 构造函数和析构函数可以调用虚函数吗,为什么
- 在C++中,不建议在构造函数和析构函数中调用虚函数。
- 构造函数和析构函数调用虚函数时都不使用动态,如果在构造函数或析构函数中调用虚函数,则运行的是基类中定义的版本。
- 因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态绑定。
- 析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义。
208. 208. 208. 构造函数、析构函数的执行顺序?构造函数和拷贝构造的内部都干了啥
- 构造函数顺序
① 基类构造函数。如果有多个基类,则构造函数的调用顺序是某类在类派生表中出现的顺序,而不是它们在成员初始化表中的顺序。
② 成员类对象构造函数。如果有多个成员类对象则构造函数的调用顺序是对象在类中被声明的顺序,而不是它们出现在成员初始化表中的顺序。
③ 派生类构造函数。
2) 析构函数顺序
① 调用派生类的析构函数;
② 调用成员类对象的析构函数;
③ 调用基类的析构函数。
209. 209. 209. 虚析构函数的作用,父类的析构函数是否要设置为虚函数
- C++中基类采用virtual虚析构函数是为了防止内存泄漏。具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。所以,为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。
- 如果父类中存在纯虚析构函数,则父类为接口类,不能被实例化,因为每一个派生类析构函数会被编译器加以扩张,以静态调用的方式调用其每一个虚基类以及上一层基类的析构函数。因此,缺乏任何一个基类析构函数的定义,就会导致链接失败,最好不要把虚析构函数定义为纯虚析构函数。
210. 210. 210. 构造函数析构函数可否抛出异常
- C++只会析构已经完成的对象,对象只有在其构造函数执行完毕才算是完全构造妥当。在构造函数中发生异常,控制权转出构造函数之外。因此,在对象如果在构造函数中发生异常,对象的析构函数不会被调用。因此会造成内存泄漏。
- 可以用智能指针来取代指针类成员,免除了抛出异常时发生资源泄漏的危机,不再需要在析构函数中手动释放资源;
- 如果控制权基于异常的因素离开析构函数,而此时正有另一个异常处于作用状态,C++会调用terminate函数让程序结束;
- 如果异常从析构函数抛出,而且没有对异常捕捉,那析构函数便是执行不全的。如果析构函数执行不全,就是没有完成他应该执行的每一件事情。
211. 211. 211. 构造函数一般不定义为虚函数的原因
(1)创建一个对象时需要确定对象的类型,而虚函数是在运行时动态确定其类型的。在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型。
(2)虚函数的调用需要虚函数表指针vptr,而该指针存放在对象的内存空间中,若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表vtable地址用来调用虚构造函数了。
(3)虚函数的作用在于通过父类的指针或者引用调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类或者引用去调用,因此就规定构造函数不能是虚函数。
212. 212. 212. 类什么时候会析构
- 对象生命周期结束,被销毁时。
- delete指向对象的指针时,或delete指向对象的基类类型指针,而其基类虚构函数是虚函数时。
- 对象i是对象o的成员,o的析构函数被调用时,对象i的析构函数也被调用。
213. 213. 213. 构造函数或者析构函数中可以调用虚函数吗
从语法上讲,调用完全没有问题。
但是从效果上看,往往不能达到需要的目的。
派生类对象构造期间进入基类的构造函数时,对象类型变成了基类类型,而不是派生类类型。 同样,进入基类析构函数时,对象也是基类类型。
214. 214. 214. 构造函数的几种关键字
default
default关键字可以显式要求编译器生成合成构造函数,防止在调用时相关构造函数类型没有定义而报错。
delete
delete关键字可以删除构造函数、赋值运算符函数等,这样在执行相应函数的时候编译器就会报错。
0
将虚函数定义为纯虚函数(纯虚函数无需定义,= 0只能出现在类内部虚函数的声明语句处,当然,也可以为纯虚函数提供定义,函数体可以定义在类的外部)
215. 215. 215. 构造函数、拷贝构造函数和赋值操作符的区别
构造函数
对象不存在,没用别的对象初始化,在创建一个新的对象时调用构造函数
拷贝构造函数
对象不存在,但是使用别的已经存在的对象来进行初始化
赋值运算符
对象存在,用别的对象给它赋值,这属于重载“=”号运算符的范畴,“=”号两侧的对象都是已存在的。
216. 216. 216. 拷贝构造函数和赋值运算符重载的区别
1、拷贝构造函数是函数,赋值运算符是运算符重载。
2、拷贝构造函数会生成新的类对象,赋值运算符不能。
3、拷贝构造函数是直接构造一个新的类对象,所以在初始化对象前不需要检查源对象和新建对象是否相同;赋值运算符需要上述操作并提供两套不同的复制策略,另外赋值运算符中如果原来的对象有内存分配则需要先把内存释放掉。
4、形参传递是调用拷贝构造函数(调用的被赋值对象的拷贝构造函数),但并不是所有出现"="的地方都是使用赋值运算符。
5、类中有指针变量时要重写析构函数、拷贝构造函数和赋值运算符。
217. 217. 217. 什么情况会自动生成默认构造函数
- 如果一个类没有任何构造函数,但它含有一个成员对象,而后者有默认构造函数,那么编译器就为该类合成出一个默认构造函数。不过这个合成操作只有在构造函数真正被需要的时候才会发生.如果一个类A含有多个成员类对象的话,那么类A的每一个构造函数必须调用每一个成员对象的默认构造函数而且必须按照类对象在类A中的声明顺序进行;
- 如果一个没有构造函数的派生类派生自一个带有默认构造函数基类,那么该派生类会合成一个构造函数调用上一层基类的默认构造函数;
- 带有一个虚函数的类
- 带有一个虚基类的类
218. 218. 218. 抽象基类为什么不能创建对象
抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。
抽象类的定义
带有纯虚函数的类称为抽象类
抽象类的作用
抽象类的主要作用是将有关的操作作为接口组织在一个继承层次结构中,由它来为派生类提供一个公共的方法,派生类可以具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。
抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。抽象类是不能定义对象的。一个纯虚函数不需要(但是可以)被定义。
纯虚函数定义
纯虚函数是一种特殊的虚函数,纯虚函数一般都在虚函数后面加上=0。
许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。纯虚函数可以让类先具有一个操作名称,而没有操作内容,让派生类在继承时再去具体地给出定义。凡是含有纯虚函数的类叫做抽象类。这种类不能声明对象,只是作为基类为派生类服务。除非在派生类中完全实现基类中所有的的纯虚函数,否则,派生类也变成了抽象类,不能实例化对象。
纯虚函数引入原因
1、为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
2、在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。
为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;)。若要使派生类为非抽象类,则编译器要求在派生类中,必须对纯虚函数予以重载以实现多态性。同时含有纯虚函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。
1、多态性
指相同对象收到不同消息或不同对象收到相同消息时产生不同的实现动作。C++支持两种多态性:编译时多态性,运行时多态性。
a.编译时多态性:通过重载函数实现
b.运行时多态性:通过虚函数实现。
2、虚函数
虚函数是在基类中被声明为virtual,并在派生类中重新定义的成员函数,可实现成员函数的动态重载。
3、抽象类
包含纯虚函数的类称为抽象类。由于抽象类包含了没有定义的纯虚函数,所以不能定义抽象类的对象。
219. 219. 219. 模板类和模板函数的区别是什么
函数模板的实例化是由编译程序在处理函数调用时自动完成的,而类模板的实例化必须由程序员在程序中显式地指定。即函数模板允许隐式调用和显式调用而类模板只能显式调用。在使用时类模板必须加具体的类型,而函数模板不必。
220. 220. 220. 多继承的优缺点,作为一个开发者怎么看待多继承
- C++允许为一个派生类指定多个基类,这样的继承结构被称做多重继承。
- 多重继承的优点很明显,就是对象可以调用多个基类中的接口;
- 如果派生类所继承的多个基类有相同的基类,而派生类对象需要调用这个祖先类的接口方法,就会容易出现二义性
- 加上全局符确定调用哪一份拷贝。比如pa.Author::eat()调用属于Author的拷贝。
- 使用虚拟继承,使得多重继承的子类只拥有基类的一份拷贝。
221. 221. 221. 模板和实现可不可以不写在一个文件里面?为什么
模板定义很特殊。由template<…>处理的任何东西都意味着编译器在当时不为它分配存储空间,它一直处于等待状态直到被一个模板实例告知。在编译时模板并不能生成真正的二进制代码,而是在类实例化时才会去找对应的模板声明和实现,在这种情况下编译器是不知道实现模板类或函数的CPP文件的存在,所以它只能找到模板类或函数的声明而找不到实现,而只好创建一个符号寄希望于链接程序找地址。模板类或函数的实现并不能被编译成二进制代码,结果链接程序找不到地址只好报错了。所以为了容易使用,几乎总是在头文件中放置全部的模板声明和定义。
222. 222. 222. 将字符串“hello world”从开始到打印到屏幕上的全过程
- 用户告诉操作系统执行HelloWorld程序(通过键盘输入等)
- 操作系统:找到helloworld程序的相关信息,检查其类型是否是可执行文件;并通过程序首部信息,确定代码和数据在可执行文件中的位置并计算出对应的磁盘块地址。
- 操作系统:创建一个新进程,将HelloWorld可执行文件映射到该进程结构,表示由该进程执行helloworld程序。
- 操作系统:为helloworld程序设置cpu上下文环境,并跳到程序开始处。
- 执行helloworld程序的第一条指令,发生缺页异常
- 操作系统:分配一页物理内存,并将代码从磁盘读入内存,然后继续执行helloworld程序
- helloword程序执行puts函数(系统调用),在显示器上写一字符串
- 操作系统:找到要将字符串送往的显示设备,通常设备是由一个进程控制的,所以,操作系统将要写的字符串送给该进程
- 操作系统:控制设备的进程告诉设备的窗口系统,它要显示该字符串,窗口系统确定这是一个合法的操作,然后将字符串转换成像素,将像素写入设备的存储映像区。
- 视频硬件将像素转换成显示器可接收的一组控制数据信号
- 显示器解释信号,激发液晶屏
- 我们在屏幕上看到了HelloWorld
223. 223. 223. 为什么拷贝构造函数必须传引用不能传值
拷贝构造函数的作用就是用来复制对象的,在使用这个对象的实例来初始化这个对象的一个新的实例。
值传递:
对于内置数据类型的传递时,直接将实参拷贝赋值给形参。
对于类类型的传递时,需要首先调用该类的拷贝构造函数来初始化形参。
引用传递:
无论对内置类型还是类类型,传递引用或指针最终都是传递的地址值,不会有拷贝构造函数的调用。
如果用传值的方式进行传参数,那么构造实参需要调用拷贝构造函数,而拷贝构造函数需要传递实参,所以会一直递归,内存溢出。
224. 224. 224. 静态函数能定义为虚函数吗?常函数呢?说说你的理解
1、static成员不属于任何类对象或类实例,所以即使给此函数加上virutal也是没有任何意义的。
2、静态成员函数没有this指针。虚函数依靠vptr和vtable来处理。vptr是一个指针,在类的构造函数中创建生成,并且只能用this指针来访问它,因为它是类的一个成员,并且vptr指向保存虚函数地址的vtable.对于静态成员函数,它没有this指针,所以无法访问vptr。
这就是为何static函数不能为virtual,虚函数的调用关系:this -> vptr -> vtable ->virtual function。
225. 225. 225. 虚函数的代价
- 带有虚函数的类,每一个类会产生一个虚函数表,用来存储指向虚成员函数的指针,增大类的存储空间。
- 带有虚函数的类的每一个对象,都会有有一个指向虚表的指针,会增加对象的空间大小。
- 不能再是内联的函数,因为内联函数在编译阶段进行替代,而虚函数在运行阶段才能确定采用哪种函数。
226. 226. 226. 说一说你了解到的移动构造函数
- 有时候我们会遇到这样一种情况,我们用对象a初始化对象b后对象a我们就不在使用了,但是对象a的空间还在(在析构之前),拷贝构造函数,实际上就是把a对象的内容复制一份到b中,那么为什么我们不能直接使用a的空间呢?这样就避免了新的空间的分配,大大降低了构造的成本。这就是移动构造函数设计的初衷;
- 拷贝构造函数中,对于指针,我们一定要采用深层复制,而移动构造函数中,对于指针,我们采用浅层复制;
- 与拷贝类似,移动也使用一个对象的值设置另一个对象的值。但是,又与拷贝不同的是,移动实现的是盗取源对象的空间,源对象将丢失其内容,其内容将被目的对象占有。移动操作发生的时候,移动值的对象是未命名的对象。这里未命名的对象就是那些临时变量,甚至都不会有名称。典型的未命名对象就是函数的返回值或者类型转换的对象。使用临时对象的值初始化另一个对象值,不会要求对对象的复制:因为临时对象不会有其它使用,因而,它的值可以被移动到目的对象。做到这些,就要使用移动构造函数和移动赋值:当使用一个临时变量对象进行构造初始化的时候,调用移动构造函数。类似的,使用未命名的变量的值赋给一个对象时,调用移动赋值操作。
227. 227. 227. 什么时候合成构造函数
- 如果一个类没有任何构造函数,但他含有一个成员对象,该成员对象含有默认构造函数,那么编译器就为该类合成一个默认构造函数,因为不合成一个默认构造函数那么该成员对象的构造函数不能调用。
- 没有任何构造函数的类派生自一个带有默认构造函数的基类,那么需要为该派生类合成一个构造函数,只有这样基类的构造函数才能被调用。
- 带有虚函数的类,虚函数的引入需要进入虚表,指向虚表的指针,该指针是在构造函数中初始化的,所以没有构造函数的话该指针无法被初始化。
- 带有一个虚基类的类。
还有要注意的地方:
- 并不是任何没有构造函数的类都会合成一个构造函数
- 编译器合成出来的构造函数并不会显示设定类内的每一个成员变量
228. 228. 228. 那什么时候需要合成拷贝构造函数呢
有三种情况会以一个对象的内容作为另一个对象的初值:
-
对一个对象做显式的初始化操作。
-
当对象被当做参数交给某个函数时。
-
函数的返回值是一个类对象时。
-
如果一个类没有拷贝构造函数,但是含有一个类类型的成员变量,该类型含有拷贝构造函数,此时
编译器会为该类合成一个拷贝构造函数;
2) 如果一个类没有拷贝构造函数,但是该类继承自含有拷贝构造函数的基类,此时编译器会为该类合
成一个拷贝构造函数;
3) 如果一个类没有拷贝构造函数,但是该类声明或继承了虚函数,此时编译器会为该类合成一个拷贝
构造函数;
4) 如果一个类没有拷贝构造函数,但是该类含有虚基类,此时编译器会为该类合成一个拷贝构造函
数;
229. 229. 229. 构造函数的执行顺序是什么
- 在派生类构造函数中,所有的虚基类及上一层基类的构造函数调用;
- 对象的vptr被初始化;
- 如果有成员初始化列表,将在构造函数体内扩展开来,这必须在vptr被设定之后才做;
- 执行函数体的代码;
230. 230. 230. 一个类中的全部构造函数的扩展过程是什么
- 在成员初始化列表中的数据成员初始化操作会被放在构造函数的函数体内,并与成员的声明顺序为顺序。
- 如果一个成员并没有出现在成员初始化列表中,但它有一个默认构造函数,那么默认构造函数必须被调用。
- 如果class有虚表,那么它必须被设定初值。
- 所有上一层的基类构造函数必须被调用。
- 所有虚基类的构造函数必须被调用。
231. 231. 231. 哪些函数不能是虚函数?把你知道的都说一说
- 构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化;
- 内联函数,内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
- 静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
- 友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
- 普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。
232. 232. 232. 什么是纯虚函数,与虚函数的区别
1、虚函数是为了实现动态绑定产生的,目的是通过基类类型的指针指向不同对象时,自动调用相应的、和基类同名的函数。虚函数需要在基类中加上virtual修饰符修饰,因为virtual会被隐式继承,所以子类中相同函数都是虚函数。当一个成员函数被声明为虚函数之后,其派生类中同名函数自动成为虚函数,在派生类中重新定义此函数时要求函数名、返回值类型、参数个数和类型全部与基类函数相同。
2、纯虚函数只是相当于一个接口名,但含有纯虚函数的类不能够实例化。
纯虚函数首先是虚函数,其次它没有函数体,取而代之的是用“=0”。
既然是虚函数,它的函数指针会被存在虚函数表中,由于纯虚函数并没有具体的函数体,因此它在虚函数表中的值就为0,而具有函数体的虚函数则是函数的具体地址。
一个类中如果有纯虚函数的话,称其为抽象类。抽象类不能用于实例化对象,否则会报错。抽象类一般用于定义一些公有的方法。子类继承抽象类也必须实现其中的纯虚函数才能实例化对象。
233. 233. 233. 链接器主要完成的任务
符号解析:每个符号都对应一个函数、一个全局变量或一个静态变量,符号解析的目的就是将每个符号引用正好和一个符号定义关联起来。
重定位:编译器和汇编器生成的代码是从地址 0 0 0 开始的,链接器通过把每个符号定义和一个内存位置关联起来,对这些节进行重定位,并修改所有对符号的引用,使它们指向符号定义所对应的内存位置。
234. 234. 234. 目标文件的分类
可重定位目标文件:包含二进制代码和数据,可以在编译时和其他可重定位目标文件合并,创建一个可执行目标文件。
可执行目标文件:包含二进制代码和数据,可以直接被复制到内存中执行。
共享目标文件:特殊类型的可重定位目标文件,可以在加载或运行时被动态加载进内存并链接。
编译器和汇编器生成可重定位目标文件,链接器生成可执行目标文件。
236. 236. 236. 符号表和符号
全局符号:由当前模块定义,并被其他模块引用的符号,对应于非静态的函数和全局变量
外部符号:由其他模块定义,并被当前模块引用的全局符号,对应于其他模块定义的非静态的函数和全局变量
局部符号:对应于带 static 属性的函数和全局变量,这些符号在当前模块的任何位置都可见,不能被其他模块引用
符号表是由汇编器构造的,每个符号被分配到目标文件的某个节中:
ABS : 不该被重定位的符号
UNDEF :未定义的符号,在本模块中引用,但是在其他地方定义
COMMON:未初始化的全局变量
现代的编译器将为初始化的全局变量分配到 COMMON,将为初始化的静态变量以及初始化为 0 0 0 的全局或静态变量分配到 .bss 节
237. 237. 237. 符号解析的过程
对于局部符号来说,编译器只允许每个模块有一个定义,静态局部变量也会有本地链接器符号,编译器确保它们有唯一的名字,所以对于这些的处理比较简单。
当编译器遇到一个不是在当前模块定义的符号时,会假设该符号是在其他模块中定义的,生成一个符号表条目,并把它交给链接器处理,如果链接器找不到该符号的定义,就会报错。
对于多重定义的全局符号,处理规则如下:
- 不允许有多个同名的强符号
- 如果有一个强符号和多个弱符号同名,那么选择强符号
- 如果有多个弱符号同名,则任意选择一个
强符号指函数和已初始化的全局变量,弱符号指未初始化的全局符号。规则 2 2 2 和规则 3 3 3 可能会引起运行时错误,比如说一个 double 和一个 int 变量重名,如果选择了 double 变量,那么可能会覆盖额外的内存。
238. 238. 238. 静态库的相关知识
所有的编译系统都提供一种机制,将所有相关的目标模块打包成一个单独的文件,称为静态库,一个可执行文件只复制静态库中被应用程序引用的目标模块。
优点:速度快,代码直接包含在内存
缺点:每个可执行文件都包含着这些代码,浪费磁盘空间。每个正在运行的程序都将函数副本放进内存中,浪费内存。如果对任何函数进行了更新,都需要重新编译所有链接了该静态库的代码
在 linux 中,静态库以一种称为存档的文件格式来存放在磁盘中,存档文件是一组连接起来的可重定位目标文件的集合,有一个头部来描述每个成员目标文件的大小和位置,以后缀 .a 标识。
在符号解析阶段,按照从左到右的顺序扫描可重定位目标文件和存档文件,维护三个集合:可重定位目标文件集合 E E E ,未解析的符号集合 U U U 和已定义的符号集合 D D D 。对于每个输入文件 f f f ,会判断该文件是目标文件还是存档文件,如果是目标文件,链接器会将 f f f 添加到 E E E 中,并修改 U U U 和 D D D 集合中的内容。如果 f f f 是存档文件,那么链接器就会匹配 U U U 中未解析的符号和存档文件成员定义的符号,如果某个存档成员 m m m 定义了符号可以解析 U U U 中的某个成员,则将 m m m 添加到 E E E 中,并修改 U U U 和 D D D 。如果完成扫描后 U U U 是非空的,则链接器会产生错误。
239. 239. 239. 重定位的过程
- 重定位节和符号定义:将所有相同类型的节合并为同一类型的聚合节,然后将运行时内存地址赋给新的聚合节、输入模块定义的每个节和输入模块定义的每个符号。
- 重定位节中的符号引用:修改代码节和数据节中对每个符号的引用,使得它们指向正确的运行时地址。执行这一步依赖于重定位条目这个数据结构。
重定位条目中包括偏移量、指向的符号、重定位类型等信息。
241. 241. 241. 动态库的相关知识
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和内存中的程序链接起来,这个过程就是动态链接,由动态链接器执行,以 .so 后缀来标识。
对于一个库只有一个 .so 文件,所有引用该库的可执行目标文件都共享这个 .so 文件中的代码和数据,在内存中,一个共享库的 .text 节的一个副本可以被不同的正在运行的进程共享。
当创建可执行文件时,没有任何动态库的代码和数据节被复制到可执行文件中,链接器只是复制了一些重定位和符号表信息,运行时通过这些来解析对动态库中代码和数据的引用。
动态链接器首先重定位相关共享库中的文本和数据到某个内存段,然后再重定位对共享库中定义符号的引用,最后动态链接器将控制传递给应用程序,从这个时刻开始,共享库的位置就固定了,并且在程序执行的过程中都不会改变。
动态链接的一些应用:
- 分发软件。对软件进行更新时,就可以使用分发动态库,用户可以下载并用它替代当前的版本,下一次运行应用程序时,应用将自动链接和加载新的共享库。
- 许多 Web 服务器生成动态内容,可以使用动态链接来更有效的生成动态内容。其思路是将动态生成内容的函数打包在共享库中,当一个浏览器的请求到达时,动态的加载和链接相关函数,然后直接调用,函数一直缓存在服务器的地址空间中,只需要简单的函数调用就可以处理随后的请求。运行时无需停止服务器就可以更新已存在的函数。
动态链接的优点在于节省内存,更新方便;缺点是每次需要先将函数调入内存,速度没有静态库要快。
242. 242. 242. 位置无关代码
共享库的一个主要目的就是允许多个运行中的进程共享内存中相同的库代码,那么多个进程是如何共享程序的副本呢?
一种方法是给每个共享库分配一个事先预备的地址空间片,然后要求加载器总是在这个地址加载共享库。这种方法对地址空间的使用效率不高,即使一个进程不使用这个库,仍然会分配空间,并且难以管理,必须保证这部分内存不会被覆盖,每次修改库以后,还要检查分配的地址空间大小是否合适,如果不适合就要找新的地址空间,每创建一个新的库,我们也要寻找地址空间,而且对于每个系统而言,库在内存中的分配都是不同的。
另一种方法是使用位置无关的代码,可以加载而无需重定位的代码称为位置无关代码。
对于共享库的数据而言,无论在内存中的何处加载一个目标模块,数据段与代码段的距离总是保持不变的,代码段中任何指令和数据段中的任何变量之间的距离都是一个运行时常量,与代码段和数据段的内存位置是无关的。编译器会在数据段开始的地方创建一个偏移量的表,每个被引用的数据都会生成一个表中的数据项,加载时动态链接器会重定位这个表中的每一项,使它包括正确的绝对地址。
对于共享库的函数而言,编译器无法预测这个函数的运行时地址,因此使用了延迟绑定的方法,将过程地址的绑定推迟到第一次调用该函数的时候。因为一个应用程序只需使用一个大的共享库的很小一部分,如果一开始就重定位,则会产生大量的额外开销,使用延迟绑定,只有第一次运行时的开销很大,之后就很快了。这个方法使用偏移量表和过程链接表来实现,一个模块定义了共享库中的函数,它就有这两个数据结构,偏移量表是数据段的一部分,过程链接表是代码段的一部分。当函数第一次执行时,偏移量表中保存的是下一条指令的地址,通过栈的信息来重写偏移量表的内容,之后就可以直接通过偏移量表来进行访问。
243. 243. 243. 为什么 if 语句比 switch 要慢
因为 if 语句要依次判断每一个分支,并且还要承担分支预测失败的风险。而 switch 是直接跳转的。如果 case 的值很大并且稀疏,不需要建立这么大的表,而是一个 if-else 树,采用二分查找的方式。如果 case 值有负数,会采用偏置,使得第一个 case 的值为 0 0 0。
244. 244. 244. 函数调用
函数调用是通过汇编语言中的 call 指令实现的,当调用 call 指令时,还隐含着将调用函数的下一个指令地址 push 进栈里的操作,随后将参数放进指定的栈中,当函数调用 ret 指令时,会将栈地址递增,将下一个指令的地址放入 PC 中,然后执行,注意这里只是简单的将栈指针递增,实际上值还是存在于内存中的。
如果传递整型或指针参数不大于 6 6 6 个,则这些参数是存放在寄存器中,其余参数是存放在栈中的。浮点数参数最多有 8 8 8 个寄存器。
如果是单线程,在任何给定的时间中只有一个函数在运行。
调用多个函数时,是通过栈指针和帧指针(可选的)来实现的,一个栈指针和一个帧指针标识了该函数可用栈的起始地址和终止地址,由于栈的特性,调用多个函数时,是可以保证它的值正确调用。如果没有帧指针,那么函数怎么正确释放呢?因为函数会计算出自己需要用到多少空间,在调用时分配了多少空间,在结束调用时会相应释放。如果存在可变长数组或者内存缓冲区,则帧指针时必要的。
对于递归函数,它要不停的将自己的地址 push 进栈中,大多数系统都会有一个对于栈深度的限制,如果递归层数较深,可能会出现错误,解决方法是可以减少递归调用的参数或者调整栈的深度。
有可能出现两个函数公用一个寄存器的情况,这样可能会导致程序运行的结果和你预期的结果不同,因为该寄存器的值可能在另一个函数中被更改,然后返回时该寄存器的值就不是你预期的值了。解决方法有两种:调用者保存和被调用者保存。调用者保存是将可能会改变的寄存器的值存储起来,不应该假设寄存器的值会保持不变。被调用者保存是所有函数之间的约定,如果某寄存器的值会被改变,需要先把它保存起来,然后把值 push 进栈中,当函数调用结束恢复栈的时候,再将值恢复。有指定的寄存器来保存可能改变的寄存器的值。
245. 245. 245. static 变量
局部的 static 变量是保存在静态存储区中的,不同函数间的局部 static 变量可以重名,符号表中会添加一些额外的信息来标识这些变量。
246. 246. 246. const
常量
const
修饰的局部变量在栈上分配空间。const
修饰的全局变量在只读存储区分配空间。const
只在编译期起作用,告诉编译器带const
的变量不能出现在赋值语句左边。对于const
修饰的局部变量,可以通过指针间接的修改。对于const
修改的全局变量,则无法更改。- 使用
const
声明时,符号表中会放入常量,对常量取地址时,才会给常量分配空间。如果没有类似的操作,会直接以符号表中的值替换,即常量折叠。如果是自定义的类型,则不会发生常量折叠。
#include <iostream>
#include <vector>
int main() {
const int data = 5;
int* p = (int*)&data;
*p = 3;
std::cout << p << ' ' << &data << std::endl;
std::cout << *p << ' ' << data << std::endl;
}
运行结果:
![[Pasted image 20230308094048.png]]
猜测编译器进行了优化,直接用符号表的值,而不是使用原地址的值。
#include <iostream>
#include <vector>
int main() {
int x = 5;
const int data = x;
int* p = (int*)&data;
*p = 3;
std::cout << p << ' ' << &data << ' ' << &x << std::endl;
std::cout << *p << ' ' << data << ' ' << x << std::endl;
}
运行结果:
![[Pasted image 20230308094235.png]]
这种情况下编译器则不会进行优化,可以间接的修改const
。
247. 247. 247. 生成相同的代码如何处理
在实例化代码时,函数代码放在一个段里,在编译后,段里会存放函数的名字,其他编译单元编译同样的模板代码,那么它们的名字是相同的。这样就可以在链接的时候区分模板实例段,达到重复代码消除的目的。