C++基础
- 1、strlen与sizeof的区别
- 2、数组指针和指针数组
- 3、引用和指针
- 4、Const
- 5、static
- 6、c和c++的内存分配方式
- 7、内存模型
- 8、全局变量和局部变量
- 9、虚函数
- 1) 什么是继承什么是多态?
- 2) 类是怎样通过虚函数实现多态的?
- 3) 虚函数的作用
- 4) 说一下virtual关键字的含义
- 5) 基类析构函数不是虚函数,会有什么影响
- 6) 虚函数表
- 7) 动态绑定机制
- 8) 虚函数表中为什么就能准确查找相应的函数指针呢?
- 9) 为什么要区分虚函数与普通函数,会带来怎样的问题
- 10) 虚函数和纯虚函数
- 附)虚函数的意义
- 11)在C语言中如何实现相当于多态性的效果
- 12)纯虚函数是否可以被实例化
- 13)虚函数不能抛出异常
- 14)构造函数和析构函数可以是虚函数吗?
- 15)C++中可以继承模板类吗?为什么
- 16)模板成员函数不可以是虚函数
- 17)虚函数表是针对类还是针对对象的?虚表存在哪里?
- 18)基类指针和派生类指针之间的转换
- 19)构造函数可以调用虚函数么?
- 10、C++ 内存分配 new/malloc和free/delete
- 11、数组和链表的区别
- 12、class和struct的区别
- 13、四种类型转换static_cast,dynamic_cast,const_cast,reinterpret_cast
- 14、C和C++的区别
- 15、Extern
- 16、volatile
- 17、重写(覆盖)、重载、隐藏的区别
- 18、静态数组和动态数组的区别
- 19、什么是野指针,怎么避免野指针
- 20、内联函数的作用
- 21、左值引用、右值引用、移动语义、完美转发
- 22、列举你用过的C++11的新特性
- 23、请你说一下你理解的C++中的 smart pointer 四个智能指针:shared_ptr , unique_ptr , weak_ptr , auto_ptr
- 24、实参传递、指针引用、引用传递的区别
- 25、什么是RAII,列举一下场景,这种方式有什么好处
- 26、内存泄漏
- 27、strcpy和strcnpy的区别
- 28、空类默认的6个函数
- 29、内存对齐的原则
- 30、数组和指针的区别
- 31、构造函数总结
- 32 、析构函数总结
- 33、构造函数能不能是虚函数?拷贝构造函数能不能是虚函数?
- 34、模板
- 35、C++虚拟继承的概念
- 36、设计模式
- 37、文件系统
- 38、explicit使用注意事项
- 39、C++11中的六大构造函数
- 40、关于创建对象的事
- 41、memcpy和memmove
- 42、重载运算符
- 43、OOP
- 44、泛型编程
- 45、lambda表达式(匿名函数)
- 46、C++ 14/17/20新特性
- 47、面试题
- 1、写一个“标准”宏MIN,这个宏输入两个参数并返回较小的一个。另外,当你写下面的代码时会发生什么事?
- 2、C语言的编译链接过程?
- 3、请你说一说 OOP 的设计模式的五项原则
- 4、说一下C++和C的区别
- 5、说一说C++中四种 cast 转换
- 6、为什么不使用C的强制转换?
- 7、拷贝构造函数、赋值构造函数、析构函数
- 8、数组和指针区别
- 9、说说使用指针需要注意什么?
- 10、你怎么理解C语言和C++的区别?
- 11、简述下C++的特点
- 12、请你说说什么是宏?为什么要少使用宏?C++有什么解决方案?
- 13、请你说说内联函数,为什么使用内联函数?需要注意什么?
- 14、内联函数和宏的区别?
- 15、字节对齐
- 16、说说内联函数和函数的区别,内联函数的作用。
- 17、说说const int *a, int const *a, const int a, int *const a, const int *const a分别是什么,有什么特点。
- 18、说说静态局部变量,全局变量,局部变量的特点,以及使用场景
- 19、静态变量什么时候初始化?
- 20、static关键字的作用
- 21、为什么静态成员函数不能访问非静态成员
- 22、静态成员函数和普通成员函数的区别
- 23、volatile和mutable
- 24、说说volatile的应用
- 25、说说原子操作
- 26、说说左值和右值
- 27、右值引用作用
- 28、说说移动语义的原理
- 29、多线程编程修改全局变量需要注意什么
- 30、对象是值传递还是引用传递
- 31、拷贝构造函数的参数类型为什么必须是引用
- 32、初始化列表使用的场景
- 33、this指针
- 34、说说C++结构体和C结构体的区别?
- 35、多态的理解
- 36、nullptr调用成员函数可以吗?为什么?
- 37、请你说说虚函数的工作机制
- 38、虚函数表在什么时候创建?每个对象都有一份虚函数表吗?
- 39、函数重载是怎么实现的?
- 40、请你来介绍一下STL的allocaotr?
- 41、请你来说一下map和set有什么区别,分别又是怎么实现的?
- 42、请你说一说C++ STL 的内存优化?
- 43、请你来回答一下include头文件的顺序以及双引号” ”和尖括号的区别?
- 44、请你说一说vector、list和deque的区别,应用,越详细越好 ?
- 45、请你来说一下STL中迭代器的作用,有指针为何还要迭代器?
- 46、请你来说一说STL迭代器删除元素?
- 47、请你说一说STL中map数据存放形式?
- 48、请你讲讲STL有什么基本组成?
- 49、请你说说STL中map与unordered_map?
- 50、请你说一说epoll原理?
- 51、n个整数的无序数组,找到每个元素后面比它大的第一个数,要求时间复杂度为O(N) ?
- 52、请你回答一下STL里resize和reserve的区别?
- 53、请你说一说stl里面set和map怎么实现的?
- 54、请你来说一下什么时候会发生段错误?
- 55、如果构造函数加private会怎样?
- 56、短路求值
- 57、排序算法比较
- 58、C++STL中sort排序算法的底层实现方式和常见问题
- 59、是否可以用memset来初始化一个类?
- 60、大端小端
- 61、负数、浮点数的存储
- 62、memset
1、strlen与sizeof的区别
(1)strlen: 是函数,在运行时才能计算,参数必须是字符型指针,且必须是以\0结尾的,当数组名作为参数传入时,实际上数组已经退化为指针,他的功能时返回字符串的长度。
(2)sizeof: 是运算符,而不是函数,在编译时就已经计算好了,用于计算数据空间的字节数。因此sizeof不能用来返回动态分配的内存空间的大小。sizeof常用于返回的类型和静态分配的对象、结构或数组所占的空间,返回值跟对象、结构、数组所储存的内容没有关系。
strlen 的返回结果是 size_t 类型(即无符号整型),而 size_t 类型绝不可能是负的。
char sArr[] = "ILOVEC";
printf("sArr的长度=%d\n", sizeof(sArr)); //7
printf("sArr的长度=%d\n", strlen(sArr)); //6(最后一位为null)
/*****************************************/
strlen("\0") = 0;
sizeof("\0") = 2;
/*****************************************/
C语言会自动在在双引号"“括起来的内容的末尾补上”\0"代表结束,ASCII中的0号位也占用一个字符。
2、数组指针和指针数组
数组指针也称行指针,
int(*p)[n]
首先说明p是一个指针,指向一个整型的一维数组,这个一维数组的长度为n。指针数组不同于数组指针int *p[n]
这是一个整型的指针数组,他有n个指针类型的数组元素。
数组指针和指针数组的区别:数组指针只有一个指针变量,可以认为是c语言里专门用来指向二维数组的,它占用内存中一个指针的储存空间;指针数组是多个指针变量,以数组的形式储存在内存中,占用多个指针的储存空间,还需要说明的一点是,同时指向二维数组时,其直接引用和数组名引用是一样的。
1、指针函数和函数指针的区别
- 定义不同
- 指针函数本质是一个函数,其返回值为指针类型
- 函数指针本质是一个指针变量,指向一个函数
- 写法不同
- 指针函数
int* func()
- 函数指针
int (*func)()
- 指针函数
- 用法不同
- 指针函数返回一个指针
- 函数指针使用过程指向一个函数。通常用于函数回调的应用场景。
函数指针:指向函数的指针变量,所以函数指针首先是一个指针变量,而且这个变量指向一个函数。C++在编译时,每一个函数都有一个入口地址,该入口地址就是函数指针所指向的地址,有了指向函数的指针变量之后,就可以用该指针变量调用函数
int(*f)(int a)
;
int foo()
{
return 5;
}
int goo()
{
return 6;
}
int main()
{
int (*funcPtr)() = foo; // funcPtr 现在指向了函数foo
funcPtr = goo; // funcPtr 现在又指向了函数goo
//但是千万不要写成funcPtr = goo();这是把goo的返回值赋值给了funcPtr
return 0;
}
3、引用和指针
引用的是一种变量类型,它用于为一个变量起一个别名。指针是一个存放地址的变量,当指针指向某个变量,这时这个里就存放了那个变量的地址。
引用和指针的区别:
(1)引用必须被初始化,指针不必;
(2)引用被初始化以后不能被改变,指针可以改变所指的对象;
(3)不存在指向空值的引用,但存在指向空值的指针;
(4)指针保存的是所指对象的地址,引用是所指对象的别名;
(5)指针通过解引用间接访问,引用是直接访问;
(6)指针更灵活,引用更安全。(比值传递高效)
首先我们要认识到,使用引用传递函数的参数时,在内存中并没有实参的副本,而是对实参直接操作。当使用传值调用时,需要给形参分配存储单元,形参变量是实参的副本,如果传递的是对象,还要调用拷贝构造函数。因此传引用调用要比传值调用效率更高,占空间更少。
使用指针作为函数的参数也可以达到引用同样的效果,但是在被调函数中同样要给形参分配存储单元,在这个意义上说,引用的效率更高。而且频繁使用“*指针变量名”的形式进行运算容易产生错误而且可阅读性较差。因此引用是个更安全高效的选择。
int a[3] = {1,2,3};
&a+1地址与&a相比,偏移了12个字节,即声明数组的空间大小;
a+1地址与a相比,偏移了4个字节,即数组中一个元素的空间大小;
&a[0]+1地址与&a[0]相比,偏移了4个字节,即数组中一个元素的空间大小;
1、常引用
如果既要提高程序的效率,又要使传递给函数的数据不在函数里被改变,可以使用常引用。
const typename & 引用名 = 变量名 const int & a = b ;
用这种方式声明的引用,不能通过引用对目标变量的值进行修改。保证了引用的安全性。
引用在可以被定义为const的情况下,应当尽量被定义成const。
2、野指针?
野指针指向的位置是不可知的。
野指针不同于空指针,空指针是指一个指针的值为null,而野指针的值并不为null,野指针会指向一段实际的内存,只是它指向哪里我们并不知情,或者是它所指向的内存空间已经被释放,所以在实际使用的过程中,我们并不能通过指针判空去识别一个指针是否为野指针。
3、指针初始化
4、Const
1)c和c++是如何定义常量的,有什么不同?
c中使用宏#define定义,c++使用const定义。const是有数据类型的常量,而宏没有。编译器对const进行静态类型安全检查,对#define仅仅是字符替换,不进行安全检查,而且在字符替换时会产生意想不到的错误。有些编译器可以对const常量进行调试,而不能对宏进行调试。
2)既然c++的const这么好,为什么还要使用宏呢?
c++无法代替宏作为卫哨,防止文件重复包含
3)说说const的作用
- const修饰普通类型的变量,告诉编译器某值是保持不变的。
- const修饰指针变量:
- const int* p : 常量指针,防止指针改变常量的值
- int *const p : 指针常量,指针指向固定的地址;
- const int *const p : 指向常量的指针常量
- const修饰参数传递:
- 值传递的 const 修饰传递,一般这种情况不需要 const 修饰
- 当 const 参数为指针时,可以防止指针被意外篡改。
- 自定义类型的参数传递,需要临时对象复制参数,对于临时对象的构造,需要调用构造函数,比较浪费时间,因此我们采取 const 外加引用传递的方法。
- const修饰返回值:防止返回值当作左值使用
- const修饰成员函数:防止成员函数修改被调用对象的值,如果我们不想修改一个调用对象的值,所有的成员函数都应当声明为 const 成员函数。
- const常量只是一个编译期间的常量,修改常量内存来修改常量,未定义行为,不要那么做
const int a = 3;
int* b = (int*) &a; // 通过强制类型转换得到a所在的内存地址
*b = 5;
#include <iostream>
using namespace std;
int func()
{
int a = 9;
return a;
}
const int fun()
{
return 9;
}
int main()
{
int c = 9;
const int a = func();
const int d = 20;
//d = c ; error
//int *p1 = &c ; error
int b = fun();
const int* p = &c;
c = 10; //改变c的值可以改变*p的值
//int *p2 = p ;
cout << a << endl;
cout << b << endl;
cout << *p << endl; //10
return 0;
}
int main() {
int m = 10;
const int n = 20; // 必须在定义的同时初始化
const int *ptr1 = &m; // 指针指向的内容不可改变 ||底层const
int * const ptr2 = &m; // 指针不可以指向其他的地方 ||顶层const
ptr1 = &n; // 正确
ptr2 = &n; // 错误,ptr2不能指向其他地方
*ptr1 = 3; // 错误,ptr1不能改变指针内容
*ptr2 = 4; // 正确
int *ptr3 = &n; // 错误,常量地址不能初始化普通指针吗,常量地址只能赋值给常量指针
const int * ptr4 = &n; // 正确,常量地址初始化常量指针
int * const ptr5; // 错误,指针常量定义时必须初始化
ptr5 = &m; // 错误,指针常量不能在定义后赋值
const int * const ptr6 = &n; // 指向“常量”的指针常量,具有常量指针和指针常量的特点,指针内容不能改变,也不能指向其他地方,定义同时要进行初始化
*ptr6 = 5; // 错误,不能改变指针内容
ptr6 = &n; // 错误,不能指向其他地方
const int * const ptr9 = &m;
const int * ptr7; // 正确
ptr7 = &m; // 正确
int* const ptr8 = &m;//error: invalid conversion from 'const int*' to 'int*'
return 0;
}
4)const和define的区别
const用于定义常量,define用于定义宏,也可以定义常量,当两者都用于定义常量时,区别为:
- const生效于编译阶段,define生效于预编译阶段;
- const定义的常量,在C语言是储存在内存中、需要额外的内存空间的;define定义的常量,运行时直接的操作数,并不会放在内存中;
- const定义常量是带类型的;define定义常量不带类型。因此define定义的常量不利于类型检查。
5、static
1、静态变量什么时候初始化?
对于C语言的全局和静态变量,初始化发生在任何代码执行之前,属于编译期初始化。
而C++标准规定:全局或静态对象当且仅当对象首次用到时才进行构造。
2、static关键字的作用
1. 修饰全局变量。该变量只能在该文件中使用,其他文件不可访问,存放在静态存储区。
2. 修饰局部变量。该变量作用域只在该局部函数里,出了函数静态局部变量不会被释放,如果未初始化默认会初始化为0。存放在静态存储区。
3. 修饰静态函数。在函数返回类型前加static,函数就定义为静态函数。函数的定义和声明在默认情况下都是extern的,但静态函数只是在声明他的文件当中可见,不能被其他文件所用。
4. 修饰成员变量,该变量为所有类对象共享,不需要this指针,并且不能和const一起使用,因为const需要this指针。
5. 修饰成员函数,用命名空间表示。
- 定义静态函数或者全局变量:当我们同时编译多个文件时,在函数返回类型或全局变量前加上static关键字,函数或全局变量即被定义为静态函数或静态全局变量。静态函数或静态全局变量只能在本源文件中使用。这就是static的隐藏属性。
- static的第二个作用是保持变量内容的持久:在变量前面加上static关键字。初始化的静态变量会在数据段分配内存,未初始化的静态变量会在BSS段分配内存。直到程序结束,静态变量始终维持前值。只不过全局静态变量和局部静态变量的作用域不一样。
- static 的第三个作用是默认初始化为 0: 全局变量也具备这一属性,因为全局变量也存储在静态数据区。在静态数据区,内存中所有的字节默认值都是 0x00 。
最后对 static 的三条基本作用做一句话总结。首先 static 的最主要功能是隐藏,其次因为 static 变量存放在静态存储区,所以它具备持久性和默认值0。 - 在c++中,static关键字可以用于定义类中的静态成员变量:类的static成员变量属于整个类拥有,对类的所有对象只有一份拷贝。使用静态数据成员,它既可以被当成全局变量那样去存储,但又被隐藏在类的内部。类中的static静态数据成员拥有一块单独的存储区,而不管创建了多少个该类的对象。所有这些对象的静态数据成员都共享这一块静态存储空间。
- 在c++中,static关键字可以用于定义类中的静态成员函数:类的static函数属于整个类所有,这个函数不接受this指针,因而只能访问类的static变量。与静态成员变量类似,类里面同样可以定义静态成员函数。只需要在函数前加上关键字static即可。如静态成员函数也是类的一部分,而不是对象的一部分。所有这些对象的静态数据成员都共享这一块静态存储空间。
静态非常量数据成员只能在类外初始化;
非静态的常量数据成员不能在类内初始化,也不能在构造函数中初始化,而只能且必须在构造函数的初始化列表中初始化;
非静态的非常量数据成员不能在类内初始化,可以在构造函数中初始化,也可以在构造函数的初始化列表中初始化
类中static不能和const一起修饰成员函数**
6、c和c++的内存分配方式
1)C的内存分配方式
(1)从静态储存区域分配内存:内存在编译时就分配好了,这块内存在程序的整个运行期间都存在,如全局变量,static变量
(2)在栈上创建:在执行函数时,函数内的局部变量的存储单元都可以在栈上创建,函数执行结束时这些储存单元被自动释放。栈分配内存运算内置于处理器指令集中,效率很高,但是分配内存容量有限
(3)从堆上分配(动态分配):程序在运行时用malloc或者new申请任意多少内存,程序员负责在何时free或delete释放内存,动态内存生存期自己决定,使用灵活。
c语言跟内存申请相关的函数主要有 alloca、calloc、malloc、free、realloc等.
(1)alloca是向栈申请内存,因此无需释放
(2)malloc分配的内存是位于堆中,并且没有初始化内存的内容,因此基本上malloc之后,调用函数memset来初始化这部分的内存空间
(3)calloc则将初始这部分的内存,设置为0
(4)realloc则对malloc申请的内存进行大小的调整
(5)申请的内存最终需要通过函数free来释放
当程序运行过程中malloc了,但是没有free的话,会造成内存泄漏.一部分的内存没有被使用,但是由于没有free,因此系统认为这部分内存还在使用,造成不断的向系统申请内存,使得系统可用内存不断减少.但是内存泄漏仅仅指程序在运行时,程序退出时,OS将回收所有的资源.因此,适当的重起一下程序,有时候还是有点作用.
【attention】
三个函数声明分别是:
void* malloc(unsigned size);
void* realloc(void* ptr , unsignde newsize);
void* calloc(size_t numElements,size_t sizeOfElements);
/**********************/
char *str;
str = (char *) malloc(15);
/**********************/
都在stdlib.h函数库内,它们的返回值都是请求系统分配的地址,如果请求失败就返回NULL.
malloc()
在内存的动态存储区中分配一块长度为size字节的连续区域,参数size为需要内存空间的长度,返回该区域的首地址
calloc()
与malloc相似,参数sizeOfElement为申请地址的单位元素长度,numElements为元素个数,即在内存中申请numElements*sizeOfElement字节大小的连续地址空间.
realloc()
给一个已经分配了地址的指针重新分配空间,参数ptr为原有的空间地址,newsize是重新申请的地址长度.
区别
(1)函数malloc()不能初始化所分配的内存空间,而函数calloc能。如果由malloc()函数分配的内存空间原来没有被使用过,则其中的每一位可能都是0;反之, 如果这部分内存曾经被分配过,则其中可能遗留有各种各样的数据。函数calloc() 会将所分配的内存空间中的每一位都初始化为零,也就是说,如果你是为字符类型或整数类型的元素分配内存,那么这些元素将保证会被初始化为0;如果你是为指针类型的元素分配内存,那么这些元素通常会被初始化为空指针;如果你为实型数据分配内存,则这些元素会被初始化为浮点型的零.
(2)malloc()向系统申请分配指定size个字节的内存空间。返回类型是void类型。void表示未确定类型指针。
(3)realloc可以给指针指定空间进行扩大或者缩小,内容不变或者相应缩小
(4)realloc是从堆上分配内存,当扩大一块内存空间时,realloc()试图直接从堆上现存的数据后面的那些字节中获得附加的字节,如果能够满足,自然天下太平;如果数据后面的字节不够,问题就出来了,那么就使用堆上第一个有足够大小的自由块,现存的数据然后就被拷贝至新的位置,而老块则放回到堆上.这句话传递的一个重要的信息就是数据可能被移动.
7、内存模型
从低地址到高地址,一个程序由代码段、数据段、BSS段组成
- 数据段:存放已经初始化的全局变量和静态变量的一块内存区域
- 代码段:存放程序执行代码的一块内存区域。只读,代码段的头部还会包含一些只读的常数变量
- BSS段:存放程序中未初始化的全局变量和静态变量的一块内存区域
- 可执行程序在运行时会多出两个区域:堆和栈
- 栈区:储存局部变量、函数参数值,栈从高地址向低地址增长,是一块连续的空间
- 堆区:动态申请内存用,堆从低地址向高地址增长
- 最后一个文件映射区,位于堆栈之间,存储动态链接库以及调用mmap函数进行的文件映射.
1、堆和栈的区别
- 堆栈空间分配不同。栈是由操作系统自动分配释放,存放函数的参数值、局部变量等,栈有着很高的效率;堆由我们分配释放,效率比栈低很多
- 堆栈缓存方式不同。栈使用的是一级缓存,它们通常都是被调用时处于存储空间中,调用完毕立即释放;堆则是存放在二级缓存中,速度要慢些。
- 空间大小:栈空间比较小,一般最多为2M,超过之后会报Overflow错误。堆空间非常大,理论上接近3G(针对32位程序来说,可以看到内存分布,1G用于内核空间,用户空间中栈、BSS、data又要占一部分,所以堆理论上可以接近3G,实际上在2G-3G之间)。
- 能否产生碎片:栈的操作与数据结构中的栈用法是类似的。‘后进先出’的原则,以至于不可能有一个空的内存块从栈被弹出。因为在它弹出之前,在它上面的后进栈的数据已经被弹出。它是严格按照栈的规则来执行。但是堆是通过new/malloc随机申请的空间,频繁的调用它们,则会产生大量的内存碎片。这是不可避免地。
- 申请后系统的响应
- 栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。
- 堆:首先应该知道操作系统有一个记录空闲内存地址的链表(内存池),当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。
函数调用所用栈部分叫做栈帧指针,帧指针(ebp起始)、栈指针(esp栈顶),函数访问都是基于帧指针。
栈帧指针一般都有专门的寄存器,通常使用ebp寄存器作为帧指针,使用esp寄存器作为栈指针。帧指针指向栈帧结构的头,存放着上一个帧栈的头部结构,栈指针指向栈顶。
int a = 0; //全局初始化区
char *p1; //全局未初始化区
void main()
{
int b; //栈
char s[] = "abc"; //栈
char *p2; //栈
char *p3 = "123456"; //123456{post.content}在常量区,p3在栈上
static int c = 0; //全局(静态)初始化区
p1 = (char *)malloc(10); //分配得来得10字节的区域在堆区
p2 = (char *)malloc(20); //分配得来得20字节的区域在堆区
strcpy(p1, "123456");
//123456{post.content}放在常量区,编译器可能会将它与p3所指向的"123456"优化成一块
}
全局变量、文件域的静态变量和类的静态成员变量在main执行之前的静态初始化过程中分配内存并初始化;局部静态变量(一般为函数内的静态变量)在第一次使用时分配内存并初始化。这里的变量包含内置数据类型和自定义类型的对象。
2、堆内存申请需要注意什么?
- 不要错误的返回指向栈内存的指针,因为该内存在函数结束时会自动消亡。
- 不要返回了常量区的内存空间。因为常量字符串存放在代码段的常量区,生命期内恒定不变,只读不可修改。
- 通过传入一级指针不能解决,因为函数内部的指针将指向新的内存地址。
- 使用二级指针
- 通过指针函数解决,返回新申请的内存空间的地址。
3、内存碎片
内存碎片通常分为内部碎片和外部碎片:
- 内部碎片是由于采用固定大小的内存分区,当一个进程不能完全使用分给它的固定内存区域时就产生了内部碎片,通常内部碎片难以完全避免;
- 外部碎片是由于某些未分配的连续内存区域太小,以至于不能满足任意进程的内存分配请求,从而不能被进程利用的内存区域。再比如堆内存的频繁申请释放,也容易产生外部碎片。
解决方法:
- 段页式管理
- 内存池
4、malloc内存管理原理
malloc 底层实现及原理
malloc函数用于动态分配内存。为了减少内存碎片和系统调用的开销,malloc其采用内存池的方式,先申请大块内存作为堆区,然后将堆区分为多个内存块,以块作为内存管理的基本单位。当用户申请内存时,直接从堆区分配一块合适的空闲块。Malloc采用隐式链表结构将堆区分成连续的、大小不一的块,包含已分配块和未分配块;同时malloc采用显式链表结构来管理所有的空闲块,即使用一个双向链表将空闲块连接起来,每一个空闲块记录了一个连续的、未分配的地址。
- 当开辟空间小于128K时,调用brk()函数;
- 当开辟的空间大于128K时,调用mmap().
- malloc采用的是内存池的管理方式,以减少内存碎片。先申请大块内存作为堆区,然后将堆区分为多个内存块。当用户申请内存时,直接从堆区分配一块合适的空闲快。采用隐式链表将所有空闲块链接起来,每一个空闲块记录了一个未分配的、连续的内存地址。
Linux进程分配内存的两种方式–brk() 和mmap()
new/delete的实现原理:
new和delete是用户进行动态内存申请和释放的操作符,operator new 和operator delete是系统提供的全局函数,
new在底层调用operator new全局函数来申请空间;
delete在底层通过operator delete全局函数来释放空间;
5、内存池
内存池也是一种对象池,我们在使用内存对象之前,先申请分配一定数量的内存块留作备用。当有新的内存需求时,就从内存池中分出一部分内存块,若内存块不够用再继续申请新的内存。当不需要此内存时,重新将此内存放入预分配的内存块中,以待下次利用。这样合理的分配回收内存使得内存分配效率得到提升。
6、内存泄漏
内存泄漏的分类:
-
堆内存泄漏 (Heap leak)。对内存指的是程序运行中根据需要分配通过malloc,realloc new等从堆中分配的一块内存,再是完成后必须通过调用对应的 free或者delete 删掉。如果程序的设计的错误导致这部分内存没有被释放,那么此后这块内存将不会被使用,就会产生Heap Leak.
-
系统资源泄露(Resource Leak)。主要指程序使用系统分配的资源比如 Bitmap,handle ,SOCKET等没有使用相应的函数释放掉,导致系统资源的浪费,严重可导致系统效能降低,系统运行不稳定。
-
没有将基类的析构函数定义为虚函数。当基类指针指向子类对象时,如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确是释放,因此造成内存泄露。
- 智能指针shared_ptr循环引用
有以下几种避免方法:
第一:良好的编码习惯,使用了内存分配的函数,一旦使用完毕,要记得使用其相应的函数释放掉。
第二:将分配的内存的指针以链表的形式自行管理,使用完毕之后从链表中删除,程序结束时可检查改链表。
第三:使用智能指针。
第四:一些常见的工具插件可以帮助检测内存泄露,如ccmalloc、Dmalloc、Leaky、Valgrind等等。
7、初始化为0的全局变量在BSS还是data区
在arm-linux-gcc这个开发环境中,如果全局变量的初始值是0,编译器会将该全局变量放在BSS段。
8、全局变量和局部变量
全局变量和局部变量的区别
(1)生命周期不一样:全局变量随主程序的创建而被创建,随主程序的销毁而销毁。局部变量在局部函数内部,退出就不存在了。
(2)使用方式不一样:通过声明后,全局变量可以再各个部分进行调用,局部变量只能在局部使用,分配在堆栈
操作系统和编译系统是怎么知道的?
操作系统和编译系统是通过内存分配位置知道的,全局变量分配在全局数据段并且在程序运行时候被加载,局部变量分配在堆栈里面
使用全局变量会有什么问题?
(1) 使用全局变量会占用大量的内存(生命周期长)
(2) 使用大量的全局变量容易造成名字冲突
(3) 当出现问题时,很难定位问题来源
C语言可以在不同的源文件中定义相同名字的全局变量吗?
不使用static的时候,两个不同的源文件都可以正常编译,但会出现链接错误,原因是有两个地方存在相同的变量,导致编译器无法识别应该使用哪一个。
关于全局变量的几点说明:
- 默认情况下,C语言中的全局变量和函数的作用域仅限于定义和声明这个函数或变量的内部,如果需要从这个C文件之外访问这些函数或者全局变量就需要使用 extern关键字进行声明,这是因为C编译器是以C文件为单位进行编译的,如果这个C文件中引用了其他文件中定义的函数或者变量,编译器将无法找到这个函数或者变量的定义,从而给出该函数或者变量未定义的错误信息。
- static关键字用于全局变量的声明时,作用类似于函数的情况,这个全局变量的作用域将局限在声明该变量的c文件内部,这个c文件之外的代码将无法访问这个变量。编译的时候将会出现类似undeference to "xxx"的报错,它是找不到xxx的,因为使用static相当于进行了文件隔离。
在头文件中定义一个全局变量,然后包含到两个不同的c文件中,希望这个全局变量能在两个文件中共用。
举例说明:项目文件夹project下有main.c、common.c和common.h三个文件,其中common.h文件分别#include在main.c和common.c文件中。现在希望声明一个字符型变量key,在main.c和common.c中公用。
有人想,既然是想两个文件都用,那就在common.h中声明一个unsigned char key,然后由于包含关系,在main.c和common.c中都是可见的,所以就能共用了。
想起来确实有道理,但是实际写出来,我们发现编译的时候编译器提示出错,一般提示大概都类似于:Error: L6200E: Symbol key multiply defined (by common.o and main.o).也就是说编译器认为我们重复定义了key这个变量。这是因为#include命令就是原封不同的把头文件中的内容搬到#include的位置,所以相当于main.c和common.c中都执行了一次unsigned char key,而C语言中全局变量是项目内(或者叫工程内)可见的,这样就造成了一个项目中两个变量key,编译器就认为是重复定义。
正确的解决办法:使用extern关键字来声明变量为外部变量。具体说就是在其中一个c文件中定义一个全局变量key,然后在另一个要使用key这个变量的c文件中使用extern关键字声明一次,说明这个变量为外部变量,是在其他的c文件中定义的全局变量。请注意我这里的用词:定义和声明。例如在main.c文件中定义变量key,在common.c文件中声明key变量为外部变量,这样这两个文件中就能共享这个变量key了。
虽然在代码中好像使用了相同的变量,但是实际上使用的是不同的变量,在每个源文件中都有单独的变量。所以,在头文件中定义static变量会造成变量多次定义,造成内存空间的浪费,而且也不是真正的全局变量。
9、虚函数
虚函数
子类重写父类方法时,不能降低访问权限,只能提高访问权限。
1) 什么是继承什么是多态?
(1)继承:子类继承父类的特征和行为,使得子类具有父类的各种属性和方法。或者子类从父类继承方法,使得子类具有父类相同的行为。
(2)多态:具有不同功能的函数可以用同一个函数名,这样就可以用同一个函数名调用不同内容的函数。在面向对象中,多态是指:向不同对象发同一个消息,不同的对象在接收时会产生不同的行为,即每个对象可以用自己的方式去响应共同的消息。
2) 类是怎样通过虚函数实现多态的?
多态是指“一个接口,多种方法”,多态性分为两类:静态多态性和动态多态性。函数重载和运算符重载实现的多态属于静态多态性,动态多态性是通过虚函数实现的。静态多态性:在程序编译时,系统会决定调用那个函数,因此静态多态性又称为编译对象。动态多态性:在程序运行过程中才动态都确定操作所针对的对象,他又称运行时的多态性。类中有虚函数存在,所以编译器会为他添加一个vptr指针,并为他们分别创建一个vtbl,vptr指向那个表。每个类都有自己的虚函数表,虚函数表的作用就是保存自己类中虚函数的地址,我们可以把虚函数表形象的看成是一个数组,这个数组的每个元素存放的是虚函数的地址,不同的vptr指向不同的虚函数表,不同的虚函数表装着对应类的虚函数地址,这样虚函数就可以完成它的任务。子类重写虚函数的地址,直接替换服了虚函数在虚函数表中的位置,因此访问虚函数表时,表中是谁就访问谁。
注意:(1)存在虚函数的类都有一个一维的虚函数表叫虚表,类的对象有一个指向虚表开始的虚指针。虚表和类对应,虚表指针和对象对应
(2)对于虚函数调用来说,每一个对象内部都有一个虚表指针,该虚表指针初始化为本类的虚表,所以程序中,不管你的对象类型如何转换,但是该对象内部的虚表指针是固定的,所以才能实现动态的对象函数调用,这就是c++多态的实现原理。
3) 虚函数的作用
(1)用于实现多态: 允许在派生类中重新定义与基类同名函数,并且可以通过基类指针或引用来访问基类和派生类中的同名函数,允许派生类中对基类虚函数的重新定义
(2)虚函数在设计上还有抽象和封装的作用
4) 说一下virtual关键字的含义
virtual是C++面向对象机制中很重要的一个关键字,类中加关键字virtual的函数被称为虚函数,基类的派生类可以通过重写虚函数,实现对基类虚函数的覆盖。
5) 基类析构函数不是虚函数,会有什么影响
如果基类的析构函数不是虚函数,删除指针时,只有基类的内存被释放,派生类的没有释放,会造成内存泄漏
6) 虚函数表
每个类的实例化对象都会拥有虚函数指针并且都排列在对象的地址首部。而它们也都是按照一定的顺序组织起来的,从而构成了一种表状结构,称为虚函数表 (virtual table) 。
基类Base它的虚函数表记录的只有自己定义的虚函数
一般覆盖继承
首先基函数的表项仍然保留,而得到正确继承的虚函数其指针将会被覆盖,而子类自己的虚函数将跟在表后。而当多重继承的时候,表项将会增多,顺序会体现为继承的顺序,并且子函数自己的虚函数将跟在第一个表项后。
C++中一个类是公用一张虚函数表的,基类有基类的虚函数表,子类是子类的虚函数表,这极大的节省了内存
包含有虚函数的类通常有一个虚表指针(在32位机器上),大小为4
1. 多继承且存在虚函数覆盖同时又存在自身定义的虚函数的类对象布局
class Base1
{
public:
int base1_1;
int base1_2;
virtual void base1_fun1() {}
virtual void base1_fun2() {}
};
class Base2
{
public:
int base2_1;
int base2_2;
virtual void base2_fun1() {}
virtual void base2_fun2() {}
};
// 多继承
class Derive1 : public Base1, public Base2
{
public:
int derive1_1;
int derive1_2;
// 基类虚函数覆盖
virtual void base1_fun1() {}
virtual void base2_fun2() {}
// 自身定义的虚函数
virtual void derive1_fun1() {}
virtual void derive1_fun2() {}
};
初步了解一下对象大小及偏移信息:
sizeof(Base1) | 12 |
---|---|
sizeof(Base1) | 12 |
sizeof(Derive1) | 32 |
offsetof(Derive1.derive1_1) | 24 |
offsetof(Derive1.derive1_2) | 28 |
结论:
- 按照基类的声明顺序, 基类的成员依次分布在继承中.
- 注意被我高亮的那两行, 已经发生了虚函数覆盖!
- 我们自己定义的虚函数呢? 怎么还是看不见?!
- Derive1的虚函数表依然是保存到第1个拥有虚函数表的那个基类的后面的.
2. 如果第1个直接基类没有虚函数(表)
class Base1
{
public:
int base1_1;
int base1_2;
};
class Base2
{
public:
int base2_1;
int base2_2;
virtual void base2_fun1() {}
virtual void base2_fun2() {}
};
// 多继承
class Derive1 : public Base1, public Base2
{
public:
int derive1_1;
int derive1_2;
// 自身定义的虚函数
virtual void derive1_fun1() {}
virtual void derive1_fun2() {}
};
sizeof(Base1) | 8 |
---|---|
sizeof(Base1) | 12 |
sizeof(Derive1) | 28 |
offsetof(Derive1.derive1_1) | 20 |
offsetof(Derive1.derive1_2) | 24 |
7) 动态绑定机制
(1)为每一个包含虚函数的类设置一个虚表(VTABLE)每当创建一个包含虚函数的类或者包含虚函数的类的派生类,编译器会为这个类创建一个VTABLE。在VTABLE中,编译器放置在这个类中,或者它的基类中所有已经声明为virtual的函数地址。如果在这个派生类中没有对基类中声明为virtual的函数进行重新定义,编译器就会使用基类这个虚函数的地址。而且所有VTABLE中虚函数地址的顺序完全相同。
初始化虚指针(VPTR)然后编译器在这个类的各个对象放置VPTR。VPTR在对象的相同位置(通常都在对象的开头)。VPTR必须被初始化为指向相应的VTABLE。
为虚函数调用插入代码:当通过基类指针调用派生类的虚函数时,编译器将在调用处插入相应代码,以实现通过VPTR找到VTABLE,并根据VTABLE中储存的正确的虚函数地址,访问正确的函数。
8) 虚函数表中为什么就能准确查找相应的函数指针呢?
因为在类设计时,虚函数表直接从基类继承过来,如果覆盖了其中某些虚函数,那么虚函数的指针就会被替换,因此可以根据指针查找调用了哪个函数
9) 为什么要区分虚函数与普通函数,会带来怎样的问题
不是虚函数不能实现多态
10) 虚函数和纯虚函数
(1) 纯虚函数
纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加
=0
:
virtual void funtion1()=0
(2) 引入原因
1.为了方便使用多态特性
2、在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:
virtual ReturnType Function()= 0
;),则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。
(3) 纯虚函数最显著的特征是:
1.它们必须在继承类中重新声明函数(不要后面的=0,否则该派生类也不能实例化),而且它们在抽象类中往往没有定义。定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。
2.纯虚函数的意义:让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的默认实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。
(4) 虚函数和纯虚函数的区别
1.虚函数和纯虚函数可以定义在同一个类中,含有纯虚函数的类被称之为抽象类,只含有虚函数的类不能称之为抽象类
2.虚函数可以直接使用,也可以被子类重载以后以多态形式调用,而纯虚函数必须在子类中实现该函数才可以使用,因为纯虚函数在基类中只有声明没有定义。
3.虚函数和纯虚函数都可以在子类中被重载,以多态形式调用
4.虚函数和纯虚函数通常存在于抽象基类当中,被继承的子类重载,目的是提供一个统一的接口
5.虚函数的定义形式: vitual{method body}
纯虚函数的定义形式: vitual{} = 0;
6.虚函数必须实现,如果不实现,编译器会报错。
7.纯虚函数不能被实例化
附)虚函数的意义
1.定义一个函数为虚函数,不代表函数为不被实现的函数。
2.定义他为虚函数是为了允许用基类的指针来调用子类的这个函数。
3.定义一个函数为纯虚函数,才代表函数没有被实现。
4.定义纯虚函数是为了实现一个接口,起到一个规范的作用,规范继承这个类的程序员必须实现这个函数。
class A
{
public:
virtual void foo()
{
cout<<"A::foo() is called"<<endl;
}
};
class B:public A
{
public:
void foo()
{
cout<<"B::foo() is called"<<endl;
}
};
int main(void)
{
A *a = new B();
a->foo(); // 在这里,a虽然是指向A的指针,但是被调用的函数(foo)却是B的!
return 0;
}
这个例子是虚函数的一个典型应用,通过这个例子,也许你就对虚函数有了一些概念。它虚就虚在所谓"推迟联编"或者"动态联编"上,一个类函数的调用并不是在编译时刻被确定的,而是在运行时刻被确定的。由于编写代码的时候并不能确定被调用的是基类的函数还是哪个派生类的函数,所以被成为"虚"函数。
虚函数只能借助于指针或者引用来达到多态的效果。
11)在C语言中如何实现相当于多态性的效果
(1) C语言通过宏编译实现编译时多态;
(2) C语言可以通过函数指针实现动态多态。
12)纯虚函数是否可以被实例化
纯虚函数不能被实例化:虚函数的原理采用虚函数表,类中含有纯虚函数时,其虚函数表不完全,有个空位,即纯虚函数在类的虚函数表中对应的表项被赋值为0,也就是指向一个不存在的函数,由于编译器绝对不允许调用一个不存在函数的可能,所以该类不能生成对象,在他的派生类中,除非重写函数,否则不能生成对象。所以纯虚函数不能实例化。
纯虚函数如何定义,为什么对于存在虚函数的类中析构函数要定义成虚函数?
virtual ~myclass() = 0;
为了实现多态进行动态绑定,将派生类对象指针绑定到基类指针上,对象销毁时,如果析构函数没有定义为虚函数,则会调用基类的析构函数,显然只能销毁部分数据,如果调用对象的析构函数,就需要将该对象的析构函数定义为虚函数,销毁时通过虚函数表来找到对应的析构函数。
13)虚函数不能抛出异常
(1)如果析构函数抛出异常,则异常点之后不会执行,如果析构函数在异常点之后执行必要的动作,比如释放某些资源,则这些动作不会执行,会造成诸如资源泄露的问题
(2)通常异常发生时,C++会调用析构函数来释放资源,若此时析构函数本身也抛出异常,则前一个异常尚未处理,又有新异常,会造成程序崩溃。
14)构造函数和析构函数可以是虚函数吗?
-
虚函数对应一个虚函数表,虚函数表其实是存储在对象的内存空间的。如果构造函数是虚的,就需要通过 虚函数表来调用,可是对象还没有实例化,也就是内存空间还没有,就没有虚函数表,所以构造函数不能是虚函数。
-
虚函数的作用在于通过父类的指针或者引用来调用它的时候能够变成调用子类的那个成员函数。而构造函数是在创建对象时自动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
-
析构函数可以使用虚函数,而且在复杂的类中,这往往是必须的。析构函数也可以是纯虚函数,但是纯虚函数必须有定义体,因为析构函数的调用是子类隐含的。
15)C++中可以继承模板类吗?为什么
不可以,string是模板类,是类,不适合继承。
16)模板成员函数不可以是虚函数
解释1:
编译器都期望在处理类的定义时就能确定这个类的虚函数表的大小,如果有类的虚函数模板函数,那么就必须要求编译器提前知道程序中所有对该类的该虚成员模板函数的调用,而这是不可行的。
解释2:
(1) 在实例化模板类时,需要创建virtual table。在模板类被实例化完成之前不能确定函数模板(包括虚函数模板,加入支持的话)会被实例化多少个。
(2) 普通成员函数模板无所谓,什么时候需要什么时候就给你实例化,编译器不用知道到底需要实例化多少个,虚函数的个数必须知道,否则这个类就无法被实例化(因为要创建virtual table)。因此,目前不支持虚函数模板。
17)虚函数表是针对类还是针对对象的?虚表存在哪里?
C++的编译器应该是保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)。 这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。 目前gcc 和微软的编译器都是将vptr放在对象内存布局的最前面。
虽然我们知道vptr指向虚函数表,那么虚函数表具体存放在内存哪个位置呢,虽然这里我们已经可以得到虚函数表的地址。实际上虚函数指针是在构造函数执行时初始化的,而虚函数表是存放在可执行文件中的。
18)基类指针和派生类指针之间的转换
static_cast和dynamic_cast一般用于基类指针和子类指针之间的类型转换
Base *P = new Derived();
Derived *pd1 = static_cast<Derived *>(P);
Derived *pd2 = dynamic_cast<Derived *>(P);
以上转换都能成功。
但是,如果 P 指向的不是子类对象,而是父类对象,如下所示:
Base *P = new Base;
Derived *pd3 = static_cast<Derived *>(P);
Derived *pd4 = dynamic_cast<Derived *>(P);
在以上转换中,static_cast转换在编译时不会报错,也可以返回一个子类对象指针(假想),但是这样是不安全的,在运行时可能会有问题,因为子类中包含父类中没有的数据和函数成员,这里需要理解转换的字面意思,转换是什么?转换就是把对象从一种类型转换到另一种类型,如果这时用 pd3 去访问子类中有但父类中没有的成员,就会出现访问越界的错误,导致程序崩溃。而dynamic_cast由于具有运行时类型检查功能,它能检查P的类型,由于上述转换是不合理的,所以它返回NULL。
19)构造函数可以调用虚函数么?
包含虚函数的类的起始地址处保存的是虚函数表的地址,这个地址值是由类的构造函数填写进去的。
在生成派生类Derive的实例时,由Derive的构造函数来调用Base的构造函数先完成基类Base的构建。Base的构造函数中调用虚函数Foo,此时从虚函数表中获取的只能是Base类的虚函数表的地址,因此虚函数Foo绑定的是Base类的Foo,只能执行Base的Foo。
在生成派生类Derive的实例时,Derive的this指针指向的地址其实首先被Base的构造函数填充一次,然后又被Derive的构造函数填充一次。
结论: 基类部分在派生类部分之前被构造,当基类构造函数执行时派生类中的数据成员还没被初始化。如果基类构造函数中的虚函数调用被解析成调用派生类的虚函数,而派生类的虚函数中又访问到未初始化的派生类数据,将导致程序出现一些未定义行为和bug。
10、C++ 内存分配 new/malloc和free/delete
(1) new/delete是操作符,可以重载,只能在C++中使用,malloc/free是函数,可以覆盖,C和C++都可以使用
(2) new可以调用对象的构造函数,对应的delete可以调用相应的析构函数
(3) malloc仅仅分配内存,free仅仅回收内存,并不执行构造函数和析构函数
(4) new/delete返回的是某种数据类型的指针,malloc/free返回的是void指针
(5) new自动计算需要分配的内存空间,malloc需要手动计算
(6) malloc/free需要库文件支持,new/delete则不需要
(7) malloc分配内存不够时可以扩容,new没有这种功能
(8) new分配失败时会抛出异常,malloc失败会返回null
(9) new分配内存的位置可以是堆,也可以是静态储存区,malloc分配内存的位置是堆
(10) 在处理数组上,new有处理数组的版本new[ ] , malloc需要计算数组大小后进行内存分配
malloc/free具体操作方式:假设你用malloc需要申请100字节,实际是申请了104个字节。把前4字节存成该块内存的实际大小,并把前4字节后的地址返回给你。 free释放的时候会根据传入的地址向前偏移4个字节 从这4字节获取具体的内存块大小并释放。(实际上的实现很可能使用8字节做为头部:其中每四个字节分别标记大小和是否正在使用)
/ ************************************* /
深入理解C++ new/delete, new []/delete[]动态内存管理
/ ************************************* /
1)C++中new、delete构件三种方式
1. new/delete
2. array new / array delete
3. placement new
- 用定位放置new操作,既可以在栈(stack)上生成对象,也可以在堆(heap)上生成对象。如本例就是在栈上生成一个对象。
- 使用语句A* p=new (mem) A;定位生成对象时,指针p和数组名mem指向同一片存储区。所以,与其说定位放置new操作是申请空间,还不如说是利用已经请好的空间,真正的申请空间的工作是在此之前完成的。
- 使用语句A *p=new (mem) A;定位生成对象时,会自动调用类A的构造函数,但是由于对象的空间不会自动释放(对象实际上是借用别人的空间),所以必须显示的调用类的析构函数,如本例中的p->~A()。
- 如果有这样一个场景,我们需要大量的申请一块类似的内存空间,然后又释放掉,比如在在一个server中对于客户端的请求,每个客户端的每一次上行数据我们都需要为此申请一块内存,当我们处理完请求给客户端下行回复时释放掉该内存,表面上看者符合c++的内存管理要求,没有什么错误,但是仔细想想很不合理,为什么我们每个请求都要重新申请一块内存呢,要知道每一次内从的申请,系统都要在内存中找到一块合适大小的连续的内存空间,这个过程是很慢的(相对而言),极端情况下,如果当前系统中有大量的内存碎片,并且我们申请的空间很大,甚至有可能失败。为什么我们不能共用一块我们事先准备好的内存呢?可以的,我们可以使用placement new来构造对象,那么就会在我们指定的内存空间中构造对象。
delete只会调用一次析造函数,因此delete基本类型的指针和数组都是可以的。
delete[ ]对每个成员调用一次析构函数,主要针对A *a = new A[10]
这种类对象的销毁,delete只会销毁a[0],后面就会产生内存泄漏。
11、数组和链表的区别
(1) 储存形式:数组是一块连续的空间,声明时要确定长度,链表是一块可以不连续的动态空间,长度可变,每个节点要保留相邻节点的指针
(2) 数据查找:数组的线性查找速度快,链表需要按节点遍历,效率低
(3) 数据的插入删除:链表可以快速的插入删除,数组需要进行大量的数据搬移
(4) 越界问题:链表不存在越界问题,数组存在
12、class和struct的区别
(1) 默认的继承访问权限:struct默认为public,class默认为private
(2) 模板为C++语言新增特性,C语言没有,只有class可以用于定义参数,而struct不可以
13、四种类型转换static_cast,dynamic_cast,const_cast,reinterpret_cast
1. static_cast <new_type> (expression)
静态转换,(1)主要用于内置数据类型之间的相互转换;(2)用于自定义类时,静态转换会判断转换类型之间的关系,如果转换类型之间没有任何关系,则编译器会报错,不可转换;(3)把void类型指针转为目标类型指针(不安全)。
//static_cast.cpp
//内置类型的转换
double dValue = 12.12;
float fValue = 3.14; // VS2013 warning C4305: “初始化”从“double”到“float”截断
int nDValue = static_cast<int>(dValue); // 12
int nFValue = static_cast<int>(fValue); // 3
//自定义类的转换
class A{};
class B : public A{};
class C{};
void main(){
A *pA = new A;
B *pB = static_cast<B*>(pA); // 编译不会报错, B类继承于A类
pB = new B;
pA = static_cast<A*>(pB); // 编译不会报错, B类继承于A类
C *pC = static_cast<C*>(pA); // 编译报错, C类与A类没有任何关系。error C2440: “static_cast”: 无法从“A *”转换为“C *”
}
2. const_cast
const_cast 比较好理解,它用来去掉表达式的 const 修饰或 volatile 修饰。换句话说,const_cast 就是用来将 const/volatile 类型转换为非 const/volatile 类型。只能改变运算对象的底层const。
3. dynamic_cast:
用于动态类型转换,只能用于含有虚函数的类,用于类层次间向上和向下转换。只能转指针或引用。向下转化时,如果是非法的对于指针返回NULL,对于引用则抛出异常。其他三种都是编译时完成的,dynamic_cast是运行时处理的,运行时需要进行类型检查。不能用于内置的基本类型之间的强制转换。使用dynamic_cast进行转换的,基类一定有虚函数,否则编译不通过。
有条件转换,动态类型转换,运行时检查类型安全(转换失败返回NULL):
1)安全的基类和子类之间的转换。
2)必须有虚函数。
3)相同基类不同子类之间的交叉转换,但结果返回NULL。
class Base {
public:
int _i;
virtual void foo() {}; //基类必须有虚函数。保持多态特性才能使用dynamic_cast
};
class Sub : public Base {
public:
char *_name[100];
void Bar() {};
};
int main() {
Base* pb = new Sub();
Sub* ps1 = static_cast<Sub*>(pb); //子类->父类,静态类型转换,正确但不推荐
Sub* ps2 = dynamic_cast<Sub*>(pb); //子类->父类,动态类型转换,正确
Base* pb2 = new Base();
Sub* ps21 = static_cast<Sub*>(pb2); //父类->子类,静态类型转换,危险!访问子类_name成员越界
Sub* ps22 = dynamic_cast<Sub*>(pb2);//父类->子类,动态类型转换,安全,但结果为NULL
return 0;
}
4. reinterpret_cast
几乎什么都可以转,比如讲int转换成指针,可能会出问题,尽量少用
为什么不适用C的强制转换?
C的强制转换表面上看起来很强大,什么都能转,但是转化不够明确,不能进行错误检测,容易出错。
14、C和C++的区别
(1) 从机制上:c是面向过程的,c++是面向对象的,提供了类,c++编写面向对象的程序比c容易
(2) 从适用的方向:c 适合要求代码体积小的,效率高的场合,如嵌入式;c++更适合上层,复杂的,Linux核心大部分是c写的,因为他是系统软件,效率要求极高。
(3) C语言是结构化编程语言,C++是面向对象编程语言
(4) C++侧重于对象而不是过程,侧重于类的设计而不是逻辑设计
C和C++的特点与区别?
一、C语言特点:
- 作为一种面向过程的结构化语言,易于调试和维护;
- 表现能力和处理能力极强,可以直接访问内存的物理地址;
- C语言实现了对硬件的编程操作,也适合于应用软件的开发;
- C语言还具有效率高,可移植性强等特点。
二、C++语言特点:
- 在C语言的基础上进行扩充和完善,使C++兼容了C语言的面向过程特点,又成为了一种面向对象的程序设计语言;
- 可以使用抽象数据类型进行基于对象的编程;
- 可以使用多继承、多态进行面向对象的编程;
- 可以担负起以模版为特征的泛型化编程。
三、C++与C语言的本质差别:
在于C++是面向对象的,而C语言是面向过程的。或者说C++是在C语言的基础上增加了面向对象程序设计的新内容,是对C语言的一次更重要的改革,使得C++成为软件开发的重要工具。
四、面向对象和面向过程
面向过程是直接将解决问题的步骤分析出来,然后用函数把步骤一步一步实现,然后再依次调用就可以了;而面向对象是将构成问题的事物,分解成若干个对象,建立对象的目的不是为了完成一个步骤,而是为了描述某个事物在解决问题过程中的行为。
面向过程思想偏向于我们做一件事的流程,首先做什么,其次做什么,最后做什么。
面向对象思想偏向于了解一个人,这个人的性格、特长是怎么样的,有没有遗传到什么能力,有没有家族病史。
15、Extern
Extern可以置于变量或者函数前,以标志变量或者函数的定义在别的文件中,提示编译器遇到此变量和函数在其他模块中找到其他定义,此外也可用于连接指定。
1) extern 声明变量在外部定义吗?
extern 声明变量,说明变量将在文件以外或在文件后面部分定义
2) extern修饰函数吗?
extern 是声明函数,暗示这个函数可能在别的源文件里面定义,没有其他作用
3) extern c的作用,用法?
extern “C”是由C++提供的链接交换的指定符号,用于告诉C++这段代码是C函数,加上extern “C”后,C++可以直接调用C函数,使用extern ”c”可以实现C++与C及其他语言的混合编程。由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名
16、volatile
访问寄存器要比访问内存快,因为CPU会优先访问该数据在寄存器中存储的结果,但是内存中的数据可能已经发生了改变,而寄存器中还保留着原来的结果。为了避免这种情况的发生,将该变量声明为volatile,告诉CPU 每次都从内存去读取数据
一个参数可以即是const又是volatile吗?
可以,一个例子是只读状态寄存器,是volatile是因为他可能被意想不到的被改变,是const告诉程序不应该试图去修改它。
编译器的优化
- 在本次线程内, 当读取一个变量时,为提高存取速度,编译器优化时有时会先把变量读取到一个寄存器中;以后,再取变量值时,就直接从寄存器中取值;当变量值在本线程里改变时,会同时把变量的新值copy到该寄存器中,以便保持一致。
- 当变量在因别的线程等而改变了值,该寄存器的值不会相应改变,从而造成应用程序读取的值和实际的变量值不一致。
- 当该寄存器在因别的线程等而改变了值,原变量的值不会改变,从而造成应用程序读取的值和实际的变量值不一致。
volatile应该解释为“直接存取原始内存地址”比较合适,“易变的”这种解释简直有点误导人。
17、重写(覆盖)、重载、隐藏的区别
1、成员函数被重载的特征
- 相同的范围(在同一个类中)
- 函数的名字相同
- 参数不同
- virtual关键字可有可无
- 函数重载不能靠返回值来进行区分
//当调用max(1, 2);时无法确定调用的是哪个,单从这一点上来说,仅返回值类型不同的重载是不应该允许的。
float max(int a, int b);
int max(int a, int b);
2、重写:派生类重写基类函数,是C++多态的表现,特征
- 不同的范围(分别位于派生类和基类)
- 函数名字相同
- 参数相同
- 返回值(即函数原型)都要与基类的函数相同
- 基类函数必须有virtual关键字
- 重写函数访问的修饰符可以不同,尽管虚函数是private,在派生类重写的函数可以是public或protect
3、隐藏
是指派生类函数屏蔽了与其名字相同的基类函数,调用函数取决于指向他的指针所声明的类型,规则如下:
- 如果派生类的函数与基类的函数同名,但是参数不同,此时无论有无virtual关键字,基类函数都会被隐藏
- 如果派生类函数与基类函数同名,且参数相同,但是基类没有virtual关键字,基类的函数被隐藏
18、静态数组和动态数组的区别
- 静态数组在编译时必须知道其长度,动态数组在运行时动态的分配数组,虽然数组的长度是固定的,但动态数组不必在编译时知道数组的长度,可以在运行时确定数组的长度。与数组变量不同,动态分配的数组将一直存在,知道程序显式的释放它为止。
- 动态分配数组时,如果数组元素具有类类型,则使用该类的默认构造函数实现初始化,没有默认构造函数的类型不能成为动态数组的元素,如果数组元素时内置型,不需要初始化。
19、什么是野指针,怎么避免野指针
- 野指针是指向不确定地址的指针变量
- 野指针产生的原因,以及解决办法如下:
指针变量声明时没有初始化: 解决办法:指针声明时初始化,可以是具体的地址值,也可以是NULL
指针变量被free或delete后没有指向NULL: 解决办法:指针指向的内存空间被释放后,指针应该指向NULL
指针操作超过了变量的作用范围: 解决办法:在变量作用域结束前,释放掉变量的地址空间,并让指针指向NULL
20、内联函数的作用
- C++支持内联函数,可以提高函数的执行效率
- 函数被内联后,编译器可以通过上下相关的优化技术对结果代码执行更深入的优化
- 当函数体比较小的时候, 内联该函数可以令目标代码更加高效. 对于存取函数以及其它函数体比较短, 性能关键的函数, 鼓励使用内联
- 宏定义在预编译时用宏替换
- 内联函数在编译阶段在调用内联函数的地方进行替换,减少了函数的调用过程,但是使得编译器文件过大,因此内联函数适合简单的函数,对于复杂的函数,即使定义了内联编译器,可能也不会按照内联的方式编译
- 内联函数相比宏定义更安全,内联函数检查参数,而宏定义只是简单的替换
- 用宏定义函数要注意所有单元加上括号,#define MUL(a, b) a b,这很危险,正确写法:#define MUL(a, b) ((a) (b))
- 内联函数可以作为某个类的成员函数,这样可以使用类的保护成员和私有成员。而当一个表达式涉及到类保护成员或私有成员时,宏就不能实现了(无法将this指针放在合适位置)。
内联函数的作用,和普通函数有什么区别?
- 在编译过程中,内联函数在函数的调用点,把函数代码全部展开,所以没有标准函数的栈帧的开辟和回退。
(如果 调用函数的开销 > 函数执行的开销,那么就建议写为内联函数 )
调用的开销:函数的栈帧的开辟和回退
执行的开销:函数体内代码执行的开销
- 内联函数只在本文件可见,编译阶段就进行了替换,所以不产生符号,所以一般在头文件中定义,这样就可以在其它文件调用。普通函数产生符号,多个文件引用头文件,会产生符号重定义的错误。
编译阶段不编译.h文件,只编译.c 或.cpp 文件
21、左值引用、右值引用、移动语义、完美转发
1) 左值、右值
概念1:
左值:可以放到等号左边的东西叫左值。
右值:不可以放到等号左边的东西就叫右值。
概念2:
左值:可以取地址并且有名字的东西就是左值。
右值:不能取地址的没有名字的东西就是右值。
int a = b + c;
a 是左值,a 有变量名,也可以取地址,可以放到等号左边, 表达式b+c 的返回值是右值,没有名字且不能取地址,
&(b+c)
不能通过编译,而且也不能放到等号左边。
int a = 4; // a 是左值,4 作为普通字面量是右值
左值一般有:
- 函数名和变量名
- 返回左值引用的函数调用
- 前置自增自减表达式++i、–i
- 由赋值表达式或赋值运算符连接的表达式(a=b, a += b 等)
- 解引用表达式*p
- 字符串字面值"abcd"
2) 纯右值、将亡值
纯右值和将亡值都属于右值。
纯右值
运算表达式产生的临时变量、不和对象关联的原始字面量、非引用返回的临时变量、lambda 表达式等都是纯右值。
举例:
- 除字符串字面值外的字面值
- 返回非引用类型的函数调用
- 后置自增自减表达式i++、i–
- 算术表达式(a+b, a*b, a&&b, a==b 等)
- 取地址表达式等(&a)
将亡值
将亡值是指C++11 新增的和右值引用相关的表达式,通常指将要被移动的对象、T&&函数的返回值、std::move 函数的返回值、
转换为T&&类型转换函数的返回值,将亡值可以理解为即将要销毁的值,通过“盗取”其它变量内存空间方式获取的值,在确保
其它变量不再被使用或者即将被销毁时,可以避免内存空间的释放和分配,延长变量值的生命周期,常用来完成移动构造或者
移动赋值的特殊任务。
class A {
xxx;
};
A a;
auto c = std::move(a); // c 是将亡值
auto d = static_cast<A&&>(a); // d 是将亡值
3) 左值引用、右值引用
左值:能对表达式取地址、或具名对象/变量。一般指表达式结束后依然存在的持久对象。
右值:不能对表达式取地址,或匿名对象。一般指表达式结束就不再存在的临时对象。
type &name = exp; // 左值引用
type &&name = exp; // 右值引用
- 左值引用
int a = 5;
int &b = a; // b 是左值引用
b = 4;
int &c = 10; // error,10 无法取地址,无法进行引用
const int &d = 10; // ok,因为是常引用,引用常量数字,这个常量数字会存储在内存中,可以取地址
可以得出结论:对于左值引用,等号右边的值必须可以取地址,如果不能取地址,则会编译失败,或者可以使用const 引用形式,但这样就只能通过引用来读取输出,不能修改数组,因为是常量引用。
- 右值引用
如果使用右值引用,那表达式等号右边的值需要是右值,可以使用std::move 函数强制把左值转换为右值。
int a = 4;
int &&b = a; // error, a 是左值
int &&c = std::move(a); // ok
右值引用和左值引用的区别:
- 左值可以寻址,而右值不可以。
- 左值可以被赋值,右值不可以被赋值,可以用来给左值赋值。
- 左值可变,右值不可变(仅对基础类型适用,用户自定义类型右值引用可以通过成员函数改变)。
4) 移动语义
(1)深拷贝、浅拷贝
class A {
public:
A(int size) : size_(size) {
data_ = new int[size];
}
A(){}
A(const A& a) {
size_ = a.size_;
data_ = a.data_; //
cout << "copy " << endl;
}
~A() {
delete[] data_;
}
int *data_;
int size_;
};
int main() {
A a(10);
A b = a;
cout << "b " << b.data_ << endl;
cout << "a " << a.data_ << endl;
return 0;
}
上面代码中,两个输出的是相同的地址,a 和b 的data_指针指向了同一块内存,这就是浅拷贝,只是数据的简单赋值,那再析构时data_内存会被释放两次,导致程序出问题,这里正常会出现double free 导致程序崩溃的,但是不知道为什么我自己测试程序却没有崩溃,能力有限,没搞明白,无论怎样,这样的程序肯定是有隐患的,如何消除这种隐患呢,
可以使用如下深拷贝:
class A {
public:
A(int size) : size_(size) {
data_ = new int[size];
}
A(){}
A(const A& a) {
size_ = a.size_;
data_ = new int[size_];
cout << "copy " << endl;
}
~A() {
delete[] data_;
}
int *data_;
int size_;
};
int main() {
A a(10);
A b = a;
cout << "b " << b.data_ << endl;
cout << "a " << a.data_ << endl;
return 0;
}
深拷贝就是再拷贝对象时,如果被拷贝对象内部还有指针引用指向其它资源,自己需要重新开辟一块新内存存储资源,而不是简单的赋值。
(2)移动语义
可以理解为转移所有权,之前的拷贝是对于别人的资源,自己重新分配一块内存存储复制过来的资源,而对于移动语义,类似于转让或者资源窃取的意思,对于那块资源,转为自己所拥有,别人不再拥有也不会再使用,通过C++11新增的移动语义可以省去很多拷贝负担,怎么利用移动语义呢,是通过移动构造函数。
class A {
public:
A(int size) : size_(size) {
data_ = new int[size];
}
A(){}
A(const A& a) {
size_ = a.size_;
data_ = new int[size_];
cout << "copy " << endl;
}
A(A&& a) {
this->data_ = a.data_;
a.data_ = nullptr;
cout << "move " << endl;
}
~A() {
if (data_ != nullptr)
{
delete[] data_;
}
}
int *data_;
int size_;
};
int main() {
A a(10);
A b = a;
A c = std::move(a); // 调用移动构造函数
return 0;
}
如果不使用std::move(),会有很大的拷贝代价,使用移动语义可以避免很多无用的拷贝,提供程序性能,C++所有的STL都实现了移动语义,方便我们使用。例如:
std::vector<string> vecs;...
std::vector<string> vecm = std::move(vecs); // 免去很多拷贝
//std::move 源码 强制转换
template <typename T>
typename remove_reference<T>::type&& move(T&& t)
{
return static_cast<typename remove_reference<T>::type &&>(t);
}
注意:移动语义仅针对于那些实现了移动构造函数的类的对象,对于那种基本类型int、float 等没有任何优化作用,还是会拷贝,因为它们实现没有对应的移动构造函数。
5) 完美转发
完美转发指可以写一个接受任意实参的函数模板,并转发到其它函数,目标函数会收到与转发函数完全相同的实参,转发函数实参是左值那目标函数实参也是左值,转发函数实参是右值那目标函数实参也是右值。
那如何实现完美转发呢,答案是使用
std::forward()。
void PrintV(int &t) {
cout << "lvalue" << endl;
}
void PrintV(int &&t) {
cout << "rvalue" << endl;
}
template<typename T>
void Test(T &&t) {
PrintV(t);
PrintV(std::forward<T>(t));
PrintV(std::move(t));
}
int main() {
Test(1); // lvalue rvalue rvalue
int a = 1;
Test(a); // lvalue lvalue rvalue
Test(std::forward<int>(a)); // lvalue rvalue rvalue
Test(std::forward<int&>(a)); // lvalue lvalue rvalue
Test(std::forward<int&&>(a)); // lvalue rvalue rvalue
return 0;
}
- Test(1):1 是右值,模板中T &&t 这种为万能引用,右值1 传到Test 函数中变成了右值引用,但是调用PrintV()时候,t 变成了左值,因为它变成了一个拥有名字的变量,所以打印lvalue,而PrintV(std::forward(t))时候,会进行完美转发,按照原来的类型转发,所以打印rvalue,PrintV(std::move(t))毫无疑问会打印rvalue。
- Test(a):a 是左值,模板中T &&这种为万能引用,左值a 传到Test 函数中变成了左值引用,所以有代码中打印。
- Test(std::forward(a)):转发为左值还是右值,依赖于T,T 是左值那就转发为左值,T 是右值那就转发为右值。
6) 返回值优化
返回值优化(RVO)是一种C++编译优化技术,当函数需要返回一个对象实例时候,就会创建一个临时对象并通过复制构造函数将目标对象复制到临时对象,这里有复制构造函数和析构函数会被多余的调用到,有代价,而通过返回值优化,C++标准允许省略调用这些复制构造函数。
那什么时候编译器会进行返回值优化呢?
- return 的值类型与函数的返回值类型相同
- return 的是一个局部对象
//看几个例子:
//示例1:
std::vector<int> return_vector(void) {
std::vector<int> tmp {1,2,3,4,5};
return tmp;
}
std::vector<int> &&rval_ref = return_vector();
//不会触发RVO,拷贝构造了一个临时的对象,临时对象的生命周期和rval_ref 绑定,等价于下面这段代码:
const std::vector<int>& rval_ref = return_vector();
//示例2:
std::vector<int>&& return_vector(void) {
std::vector<int> tmp {1,2,3,4,5};
return std::move(tmp);
}
std::vector<int> &&rval_ref = return_vector();
//这段代码会造成运行时错误,因为rval_ref 引用了被析构的tmp。讲道理来说这段代码是错的,但我自己运行过程中却成功了,
//我没有那么幸运,这里不纠结,继续向下看什么时候会触发RVO。
//示例3:
std::vector<int> return_vector(void){
std::vector<int> tmp {1,2,3,4,5};
return std::move(tmp);
}
std::vector<int> &&rval_ref = return_vector();
//和示例1 类似,std::move 一个临时对象是没有必要的,也会忽略掉返回值优化。
//最好的代码:
std::vector<int> return_vector(void){
std::vector<int> tmp {1,2,3,4,5};
return tmp;
}
std::vector<int> rval_ref = return_vector();
//这段代码会触发RVO,不拷贝也不移动,不生成临时对象。
22、列举你用过的C++11的新特性
- long long类型:扩展精度浮点数,10位有效数字
- auto关键字:编译器可以根据初始值自动推导出类型。但是不能用于函数传参以及数组类型的推导
- decltype关键字:
decltype(exp) varname = value;
根据表达式exp推导varname的类型(exp不能是viod类型) - nullptr关键字:nullptr是一种特殊类型的字面值,它可以被转换成任意其它的指针类型;而NULL一般被宏定义为0,在遇到重载时可能会出现问题
- 智能指针:C++11新增了std::shared_ptr、std::weak_ptr等类型的智能指针,用于解决内存管理的问题。
- 初始化列表:使用初始化列表来对类进行初始化
- 右值引用:基于右值引用可以实现移动语义和完美转发,消除两个对象交互时不必要的对象拷贝,节省运算存储资源,提高效率
- atomic原子操作用于多线程资源互斥操作
- 新增STL容器array以及tuple
1、auto类型推导的原理
auto使用的是模板实参推断(Template Argument Deduction)的机制。auto被一个虚构的模板类型参数T替代,然后进行推断,即相当于把变量设为一个函数参数,将其传递给模板并推断为实参,auto相当于利用了其中进行的实参推断,承担了模板参数T的作用。比如
template<typename Container>
void useContainer(const Container& container)
{
auto pos = container.begin();
while (pos != container.end())
{
auto& element = *pos++;
… // 对元素进行操作
}
}
其中第一个auto的初始化相当于下面这个模板传参时的情形,T就是为auto推断的类型
// auto pos = container.begin()的推断等价于如下调用模板的推断
template<typename T>
void deducePos(T pos);
deducePos(container.begin());
而auto类型变量不会是引用类型(模板实参推断的规则),所以要用auto&(C++14支持直接用decltype(auto)推断原始类型),第二个auto推断对应于下面这个模板传参时的情形,同样T就是为auto推断的类型
// auto& element = *pos++的推断等价于如下调用模板的推断
template<typename T>
void deduceElement(T& element);
deduceElement(*pos++);
唯一例外的是对初始化列表的推断,auto会将其视为std::initializer_list,而模板则不能对其推断
auto x = { 1, 2 }; // C++14禁止了对auto用initializer_list直接初始化,必须用=
auto x2 { 1 }; // 保留了单元素列表的直接初始化,但不会将其视为initializer_list
std::cout << typeid(x).name(); // class std::initializer_list<int>
std::cout << typeid(x2).name(); // C++14中为int
template<typename T>
void deduceX(T x);
deduceX(x); // 错误:不能推断
C++14还允许auto作为返回类型,但此时auto仍然使用的是模板实参推断的机制,因此返回类型为auto的函数如果返回一个初始化列表,则会出错
auto newInitList() { return { 1 }; } // 错误
decltype比auto更确切地推断名称或表达式的类型(即原始的declared type),实现原理应该和auto类似,只是特殊情况不太一样,具体实现需要更多考虑
int i = 0;
decltype(i) x; // int x
decltype((i)) y = i; // int& y
decltype(i = 1) z = i; // int& z
std::cout << i << z; // 00
2、nullptr
一、nullptr与nullptr_t
- nullptr_t是一种数据类型,而nullptr是该类型的一个实例。通常情况下,也可以通过nullptr_t类型创建另一个新的实例。
- 所有定义为nullptr_t类型的数据都是等价的,行为也是完全一致的。
- std::nullptr_t类型,并不是指针类型,但可以隐式转换成任意一个指针类型(注意不能转换为非指针类型,强转也不行)。
- nullptr_t类型的数据不适用于算术运算表达式。但可以用于关系运算表达式(仅能与nullptr_t类型数据或指针类型数据进行比较,当且仅当关系运算符为==、<=、>=等时)
二、 nullptr与NULL的区别
1. NULL是一个宏定义,C++中通常将其定义为0,编译器总是优先把它当作一个整型常量(C标准下定义为(void*)0)。
2. nullptr是一个编译期常量,其类型为nullptr_t。它既不是整型类型,也不是指针类型。
3. 在 模板推导中, nullptr被推导为nullptr_t类型,仍可隐式转为指针。 但0或NULL则会被推导为整型类型。
4. 要避免在整型和指针间进行函数重载。因为NULL会被匹配到整型形参版本的函数,而不是预期的指针版本。
三、 nullptr与(void*)0的区别
- nullptr到任何指针的转换是隐式的。(尽管nullptr不是指针类型,但仍可当指针使用)
- (void*)0只是一个强制转换表达式,其返回void*指针类型,只能经过类型转换到其他指针才能用(C++中不能将void *类型的指针隐式转换成其他指针类型)。
23、请你说一下你理解的C++中的 smart pointer 四个智能指针:shared_ptr , unique_ptr , weak_ptr , auto_ptr
1、智能指针是利用一种叫做RAII(资源获取初始化)的技术对普通的指针进行封装,这时智能指针实质上是一个对象,行为表现得像一个指针;
2、智能指针的作用:防止忘记调用delete释放内存和程序异常的进入catch块忘记释放内存,另外指针的释放时机也是非常考究的,多次释放同一个指针,会造成程序崩溃,这些都可以通过智能指针来解决。智能指针还有一个作用就是把值语义转化成引用语义;
3、智能指针的使用:智能指针是C++11中提供的,包含在头文件,shared_ptr,unique_ptr,weak_ptr
4、 shared_ptr : 多个指针指向同一个对象,share_ptr适用引用计数,每一个shared_ptr的拷贝都指向相同的内存,每使用一次,内部计数加1,每析构一次,内部引用次数减1,减为0时,自动删除所有指向堆的内存。shared_ptr内部的引用计数的线程是安全的,但是对象的读取需要加锁。
(1) 初始化:智能指针是一个模板类,
- 裸指针直接初始化,但不能通过隐式转换来构造,因为shared_ptr构造函数被声明为explicit;
- 允许移动构造,也允许拷贝构造;
- 通过make_shared构造,在C++11版本中就已经支持了**不能将指针直接赋给一个智能指针,一个是类,一个是指针。
shared_ptr<int> p1(new int(100));
shared_ptr<int> p2(std::move(p1)); // 移动语义,移动构造一个新的智能指针p2
// p1就不再指向该对象(变成空),引用计数依旧是1
shared_ptr<int> p3;
p3 = std::move(p2); // 移动赋值,p2指向空, p3指向该对象,整个对象的引用计数仍旧为1
移动肯定比复制快;复制你要增加引用技术,移动不需要,
移动构造函数快过复制构造函数,移动赋值运算符快过拷贝赋值运算符
#include <iostream>
#include <memory>
class Frame {};
int main()
{
std::shared_ptr<Frame> f(new Frame()); // 裸指针直接初始化
std::shared_ptr<Frame> f1 = new Frame(); // Error,explicit禁止隐式初始化
std::shared_ptr<Frame> f2(f); // 拷贝构造函数
std::shared_ptr<Frame> f3 = f; // 拷贝构造函数
f2 = f; // copy赋值运算符重载
std::cout << f3.use_count() << " " << f3.unique() << std::endl;
std::shared_ptr<Frame> f4(std::move(new Frame())); // 移动构造函数
std::shared_ptr<Frame> f5 = std::move(new Frame()); // Error,explicit禁止隐式初始化
std::shared_ptr<Frame> f6(std::move(f4)); // 移动构造函数
std::shared_ptr<Frame> f7 = std::move(f6); // 移动构造函数
std::cout << f7.use_count() << " " << f7.unique() << std::endl;
std::shared_ptr<Frame[]> f8(new Frame[10]()); // Error,管理动态数组时,需要指定删除器
std::shared_ptr<Frame> f9(new Frame[10](), std::default_delete<Frame[]>());
auto f10 = std::make_shared<Frame>(); // std::make_shared来创建
return 0;
}
(2) 拷贝与赋值:拷贝时对象引用计数加一,赋值使得引用计数减一(原来指向对象的引用计数),当计数为0时,自动释放内存,后来指向对象引用计数加一,指向后来的对象。
p = q
p q必须都是shared_ptr,所保存的指针必须能相互转换,此操作会让p引用计数减一,q引用计数加一,p计数变为0时,则释放其管理的内存。
(3) get函数获取原始指针,若智能指针释放其对象,则返回指针所指对象也不复存在。
(4) 注意,不要用一个原始指针初始化多个share_ptr,否则会造成二次释放同一内存。
(5) 注意避免循环引用,share_ptr最大的陷阱就是循环引用,会导致内存无法释放,导致内存泄漏。
shared_ptr内存模型
由图可以看出,shared_ptr包含了一个指向对象的指针和一个指向控制块的指针。每一个由shared_ptr管理的对象都有一个控制块,它除了包含强引用计数、弱引用计数之外,还包含了自定义删除器的副本和分配器的副本以及其他附加数据。
控制块的创建规则:
- std::make_shared总是创建一个控制块;
- 从具备所有权的指针出发构造一个std::shared_ptr时,会创建一个控制块(如std::unique_ptr转为shared_ptr时会创建控制块,因为unique_ptr本身不使用控制块,同时unique_ptr置空);
- 当std::shared_ptr构造函数使用裸指针(int *p)作为实参时,会创建一个控制块。这意味从同一个裸指针出发来构造不止一个std::shared_ptr时会创建多重的控制块,也意味着对象会被析构多次。如果想从一个己经拥有控制块的对象出发创建一个std::shared_ptr,可以传递一个shared_ptr或weak_ptr而非裸指针作为构造函数的实参,这样则不会创建新的控制块。
智能指针详解
智能指针线程安全
shared_ptr共享型智能指针
5、 unique_ptr: 唯一拥有所指对象,同一时刻只能有一个unique_ptr指向给定的对象(通过禁止拷贝语义,只有移动语义来实现)相比于原始指针unique_ptr用语气RAII的特性,使得再出现异常的情况下,动态资源都能得到释放。
unique_ptr本身的生命周期:从unique_ptr指针创建时开始,直到离开作用域。离开作用域时,若其指向对象,则将其所指对象销毁。
unique_ptr指针与其所指对象的关系:在智能指针生命周期内,可以改变智能指针所指对象。
6、weak_ptr: 是为了配合share_ptr而引入的一种智能指针,因此他不具有普通指针的行为,没有重载,他的最大作用是协助share_ptr工作,像旁观者那样观测资源的使用情况。weak_ptr可以从share_ptr或者另一个weak_ptr对象构造,获得观测权,但weak_ptr没有共享资源,他的构造不会引起指针引用计数的增加,使用weak_ptr的成员函数use_count()可以观测资源的引用计数,另一个成员函数expirred()的功能等价于use_count()==0,表示被观测的资源已经不存在了。weak_ptr可以使用一个更重要的成员函数lock()从被观测的share_ptr获得一个可用的share_ptr对象,从而操作资源,当expirred()==true的时候,lock()函数将返回一个存储空指针的share_ptr。
注意的是我们不能通过weak_ptr直接访问对象的方法,比如B对象中有一个方法print(),我们不能这样问,pa->pb->print(); 英文pb是一个weak_ptr,应该先把它转化为shared_ptr,如:shared_ptr p =pa->pb_.lock(); p->print();
7、 智能指针的设计与实现 :智能指针类讲一个计数器与类所指向的对象相关联,引用计数跟踪该类有多少个对象共享一个指针,每次传建类的新对象时,初始指针并将引用计数器置1,当对象作为另一个对象的副本而建立的时候,拷贝构造函数拷贝指针并增加与之相对用的引用计数,对一个对象进行赋值,赋值使得原对象的引用计数减1,并增加当前对象的引用计数加1.调用析构函数时,构造函数减少引用计数(如果引用计数减少至0,则删除基础对象)智能指针就是模拟指针动作的类。所有的智能指针都会重载 -> 和 * 操作符。智能指针还有许多其他功能,比较有用的是自动销毁。这主要是利用栈对象的有限作用域以及临时对象(有限作用域实现)析构函数释放内存。
24、实参传递、指针引用、引用传递的区别
- 实参传递: 形参是实参的副本(复制、拷贝),形参值的改变是不会影响实参的值,这种方式很常见
- 指针传递: 形参是指针类型,形参做指针运算后指向的就是实参,所以会影响实参的值
- 引用传递: 在调用函数时将实际参数的地址传递到函数中,那么函数中对引用进行的修改,将会影响实际参数
25、什么是RAII,列举一下场景,这种方式有什么好处
1) 什么是RAII
- RAII直译过来是“资源获取即初始化”,也就是说在构造函数中申请分配资源,在析构函数中释放资源。因为C++的语言机制保证了,当一个对象创建的时候,自动调用构造函数,当对象超出作用域的时候会自动调用析构函数。所以,在RAII的指导下,我们应该使用类来管理资源,将资源和对象的生命周期绑定。
- 智能指针(std::shared_ptr和std::unique_ptr)即RAII最具代表的实现,使用智能指针,可以实现自动的内存管理,再也不需要担心忘记delete造成的内存泄漏。毫不夸张的来讲,有了智能指针,代码中几乎不需要再出现delete了
2) 使用环境
- 如读写文件的时候很容易忘记关闭文件,如果借用 RAII技术,就可以规避这种错误。
- 再如对数据库的访问,忘记断开数据库连接等等都可以借助RAII 技术也解决。
3) 使用的好处
- 使用RAII完全不担心资源释放的问题
- 使用RAII不需要显式的释放资源
- 采用这种方式,对象所需的资源在其生命期内始终保持有效
26、内存泄漏
1) 造成内存泄露常见的几种情况
- malloc分配的指针被重新赋值
char * p = (char *)malloc(10);
char * np = (char *)malloc(10);
p = np;
- 错误的内存释放
free(p); // 泄露
free(p->np); //不泄露
free(p);
- 返回值的不正确处理
char *f(){
return (char *)malloc(10);
}
void f1(){
f();
}
//函数 f1 中对 f 函数的调用并未处理该内存位置的返回地址,其结果将导致 f 函数所分配的 10 个字节的块丢失,并导致内存泄漏。
- 在内存分配后忘记使用 free 进行释放
- 不匹配使用new[ ] 和 delete[ ]
- delete void * 的指针,导致没有调用到对象的析构函数,析构的所有清理工作都没有去执行从而导致内存的泄露;
- 没有将基类的析构函数定义为虚函数,当基类的指针指向子类时,delete该对象时,不会调用子类的析构函数
27、strcpy和strcnpy的区别
- strcpy函数:字符串复制函数,原型:char *strcpy(char *dest,char *src)功能:把从src地址开始且含有NULL结束符的字符串赋值到以dest开始的地址空间,返回dest。要求:src和dest所指内存区域不可以重叠且dest必须有足够的空间容纳src的字符串。
- strcnpy函数:n代表可以指定字符个数进行赋值。原型:char * strncpy(char *dest, char *src, size_tn); 将字符串src中最多n个字符复制到字符数组dest中(它并不像strcpy一样遇到NULL才停止复制,而是等凑够n个字符才开始复制),返回指向dest的指针。要求:如果n > dest串长度,dest栈空间溢出产生崩溃异常。
(1)src串长度 <= dest串长度
(1)如果n=(0,src串长度),src的前n个字符复制到dest中。但是由于没有NULL字符,所以直接访问dest串会发生栈溢出的异常情况。这时,一般建议采取memset将dest的全部元素用null填充
(2)如果n = src串长度,与strcpy一致
(3)如果n = dest串长度,[0,src串长度]处存放于desk字串,[src串长度, dest串长度]处存放NULL。
(2)src串长度>dest串长度
如果n = dest串长度,则dest串没有NULL字符,会导致输出会有乱码。如果不考虑src串复制完整性,可以将dest最后一字符置为NULL。所以,一般把n设为dest(含null)的长度(除非将多个src复制到dest中)。当2)中n = dest串长度时,定义dest为字符数组,因为这时没有null字符拷贝。
28、空类默认的6个函数
- 缺省的构造函数
- 缺省的拷贝构造函数
- 缺省的析构函数
- 缺省的赋值运算符
- 缺省的取地址符
- 缺省的取地址符const
29、内存对齐的原则
- 从0位置开始储存
- 变量储存的起始位置是该变量大小的整数倍
- 结构体总的大小是其最大元素的整数倍,不足的后面要补齐
- 结构体中包含结构体,从结构体中最大元素的整数倍开始存
- 如果加入pragmaticpack(in),取n和变量自身大小较小的一个
1. 什么是字节对齐?
#include<stdio.h>
#include<stdint.h>
struct test
{
int a;
char b;
int c;
short d;
};
typedef union
{
double i;
int k[5];
char c;
}DATE;
int main(int argc,char *argv)
{
/*在32位和64位的机器上,size_t的大小不同*/
printf("the size of struct test is %zu\n",sizeof(struct test));
return 0;
}
结构体
未对齐时:
0~3 | 4 | 5~9 | 10~11 |
---|---|---|---|
a | b | c | d |
对齐时:
0~3 | 4 | 5~7 | 8~11 | 12 ~ 13 | 14 ~ 15 |
---|---|---|---|---|---|
a | b | 填充内容 | c | d | 填充 |
联合
union中最大的变量类型是int[5],所以占用20个字节,大小是20,由于double占8字节,要进行8字节对齐,所以内存空间是8的倍数,最后所占空间是24。
总的来说,字节对齐有以下准则:
- 结构体变量的首地址能够被其对齐字节数大小所整除。
- 结构体每个成员相对结构体首地址的偏移都是成员大小的整数倍,如不满足,对前一个成员填充字节以满足。
- 结构体的总大小为结构体对最大成员大小的整数倍,如不满足,最后填充字节以满足。
- 数组 :按照基本数据类型对齐,第一个对齐了后面的自然也就对齐了。联合 :按其包含的长度最大的数据类型对齐。结构体:结构体中每个数据类型都要对齐。
注意:64位机器指针大小为8字节,32为机器为4字节
当结构体中有复合符合成员时,复合成员相对于结构体首地址偏移量是复合成员最宽基本类型大小的整数倍。
2. 为什么要字节对齐?
- 根本原因:影响CPU访问效率,操作系统并非一个字节一个字节访问内存,而是按2,4,8这样的字长来访问。因此,当CPU从存储器读数据到寄存器,IO的数据长度通常是字长。如32位系统访问粒度是4字节(bytes), 64位系统的是8字节。当被访问的数据长度为n字节且该数据地址为n字节对齐时,那么操作系统就可以高效地一次定位到数据, 无需多次读取,处理对齐运算等额外操作。数据结构应该尽可能地在自然边界上对齐。如果访问未对齐的内存,CPU需要做两次内存访问。
- 一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。
- 各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始
#pragma pack () 取消指定对齐,恢复缺省对齐
#pragma pack (2) 2字节对齐
3. 联合体赋值问题
给联合中的成员赋值时,只会对这个成员所属的数据类型所占内存空间的大小覆盖成后来的这个值,而不会影响其他位置的值。
- 成员为两个int类型变量,第一次给a赋值为20,它占4个字节,第二次给b赋值10,会把10(高位补零)覆盖int类型所占的4个字节(32位),所以最后a和b都是10。
- 成员a为int类型,b为short类型,第一次给a赋值100000,二进制为0000 0000 0000 0001 1000 0110 1010 0000,第二次给b赋值,由于short只占2个字节,所以只会覆盖16位二进制,0000 0000 0000 1010,最后的结果是0000 0000 0000 0001 0000 0000 0000 1010(从低位覆盖),所以输出结果为65546。
- 由于给int类型的a赋值的二进制展开只有在低两个字节内出现1,所以给short类型的b赋值时,会把a全部覆盖掉,所以两个的结果都为10。
4. 字长
一个字的位数,现代电脑的字长通常为16,32, 64位。(一般N位系统的字长是N/8字节。)
不同的CPU一次可以处理的数据位数是不同的,32位CPU可以一次处理32位数据,64位CPU可以一次处理64位数据,这里的位,指的就是字长。
30、数组和指针的区别
- 指针是一类特殊的变量,主要用途是函数间的传址,用这种方式改变实参的内容,而数组是实现线性表的结构,用于把同类对象放在一起
- 数组要么在静态储存区被创建(全局数组),要么在栈上被创建。指针可以随时指向任意类型内存块
- 用运算符sizeof可以计算数组容量,当时指针只能计算一个指针变量的字节数(指针类型的大小是固定的(无论该指针指向哪种数据类型),在32位系统中为4字节;在64位系统中为8字节),而不是指向内存块的大小
31、构造函数总结
- 构造函数是处理数据成员初始化的
- 构造函数不需要用对象调用,建立对象时会自动执行
- 构造函数必须与类名字相同
- 构造函数无返回值
- 构造函数可以在类外定义
- 构造函数的函数体中,不仅可以对数据成员进行赋值,也可以包含其他语句
- 如果用户没有定义构造函数,系统会自动生成一个,只是构造函数内部是空的,不需要初始化
32 、析构函数总结
- 析构函数在对象声明周期结束时自动执行
- 析构函数没有返回值,并且析构函数不能有参数
- 析构函数的作用不是删除对象,而是在撤销对象占用的内存前完成的一些清理工作,使得这些内存可以供新对象使用
- 编译器会自动生成析构函数
- 调用构造函数的四大场景
(1)如果函数中定义了一个对象,当这个函数调用结束时,对象被释放,且在对象释放前会自动执行析构函数
(2)static局部对象在函数调用结束时对象不释放,所以也不执行析构函数,只有在main函数结束或调用exit函数结束程序时,才调用static局部对象的析构函数
(3)全局对象则在程序流程离开作用域时,才会执行该全局对象的析构函数
(4)用new建立对象,用delete释放对象时,会调用该对象的析构函数
33、构造函数能不能是虚函数?拷贝构造函数能不能是虚函数?
不可以为虚函数,因为调用构造函数时,虚表指针并没有在对象的内存空间中,必须要构造函数调用完后才会形成虚表指针
拷贝构造函数理由同上
34、模板
1)模板和实现可不可以不写在一个文件里面?为什么?
只能写在一个一个头文件中。模板类的实现,脱离具体的使用,是无法单独的编译的;把声明和实现分开的做法也是不可取的,必须把实现全部写在头文件里面
原因: 多文件处理变为一个文件其实是通过链接器来实现的,所以如果用源文件来处理模板实现,会导致链接失效,最主要的原因还是在编译,编译器会暂时不处理模板类只有在实例化对象时才去处理,但是这就需要实现的代码了,如果放在其他文件的话,就会无法形成相应的类。
2)函数模板和函数重载有什么区别?
函数的重载:
C++允许用同一函数名定义多个函数,这些函数的参数个数和参数类型不同。这就是函数重载。
重载函数的参数个数、参数类型或参数顺序3者中必须至少有一种不同,函数返回值类型可以相同也可以不同。
函数模板:
所谓函数模板。实际上是建立一个通用函数,其函数类型和形参类型不具体指定,用一个虚拟的类型来代表。这个通用函数就称为函数模板。凡是函数体相同的函数都可以用这个模板来代替,不必定以多个函数,只需在模板中定义一次即可。
template < typename T> //模板声明。template的含义是“模板”。关键字typename或class表示“类型名”。其中T为类型参数,类型参数可以不只一个,可以根据需要确定个数。
T max (T a,T b,T c) //定义一个通用函数,用T作虚拟的类型名
模板只适用于函数体相同、函数的参数个数相同而类型不同的情况,如果参数的个数不同,则不能用函数模板。
方法 | 返回值类型 | 参数个数 | 参数类型 | 参数顺序 | 函数体 |
---|---|---|---|---|---|
重载 | 可同也可不同 | 必须有一种不同 | 不同 | ||
模板 | 相同 | 相同 | 不同 | 相同 | 相同 |
1.函数模板与同名的非模板函数重载的时候,两者调用顺序
首先编译不会出错的
函数模板与同名的非模板函数重载时候,调用顺序:
- 寻找一个参数完全匹配的函数,如果找到了就调用它
- 寻找一个函数模板,将其实例化,产生一个匹配的模板函数,若找到了,就调用它
- 若1,2都失败,再试一试低一级的对函数的重载方法,例如通过类型转换可产生参数匹配等,若找到了,就调用它
- 若1,2,3均未找到匹配的函数,则是一个错误的调用
2、模板特化
3、模板类型推导
一、 函数模板及调用形式
template<typename T>
void f(ParamType param); //注意这里是ParamType而不是T。这两者可能不一样!
f(expr); //调用形式,以实参expr调用f
- T和ParamType的类型往往不一样。因为ParamType常包含一些修饰词,如const或引用符号等限定词。
- T的类型,不仅仅依赖于实参expr的类型,还依赖于ParamType的类型。
- ParamType的形式可分为三种情况:A. ParamType是个指针或引用类型(非万能引用)。B. ParamType是一个万能引用。C. ParamType既非指针也非引用。
二、模板推导规则
规则1: ParamType是个指针或引用(但非万能引用),即T*或T&等。
- 若expr是个引用类型,先将其引用忽略
- 然后对expr的类型和ParamType的类型进行模式匹配,来决定T的类型。
注意事项:
- ParamType是个引用类型。由于引用的特点,即形参代表实参本身,所以实参expr的CV属性会被T保留下来。(注意,如果传进来的实参是个指针,则param会将实参(指针)的顶层和底层const的都保留下来)。
- 当ParamType为T* 指针,传参时param指针是实参的副本,因此实参和形参是两个不同的指针。T在推导中,会保留实参中的底层const,而舍弃顶层const。因为底层const修饰的是指针所向对象,表示该对象不可更改,所以const应保留,而顶层const表示指针本身,从实参到形参传递时复制的是指针,因此形参的指针是个副本,无须保留const属性。(如,const char* const ptr中,顶层const指修饰ptr的const,即号右侧const,而底层const指号左侧的const)
规则2:ParamType为万能引用,即T&&
- 如果实参expr是个左值,T和ParamType会被推导为左值引用。(注意,这是模板类型推导中,T唯一被推导为引用(T&)的情形)
- 如果要实参expr是个右值,则应用“规则1”来推导。 此时的T被推导为T。
注意事项:
- 当实参为左值时T被推导为T&(不是T类型),表示形参是实参的引用,即代表实参本身。因此指针的顶层和底层const属性均会被保留。
- 形如const T&&或vector&&)均属于右值引用,因为const的引用会剥夺引用成为万能引用的资格,因为由其定义的变量再也不能成为非const类型,所以不是“万能”的类型,而后者己经确定是个vector类型,不可能成为“万能”的类型,如不会再是int型,因此也不是万能引用。(详见《万能引用》一节)
- 在推导过程中会发生引用折叠(详见《引用折叠》一节)。
规则3:ParamType是个非指针也非引用类型(即按值传递)
- 若实参是个引用类型,则忽略其引用部分,同时cv属性被忽略。(因为按值传递,采用复制手段,形参是个副本,与实参是两个不同的参数)。
- 如果实参是指针类型,从实参到形参传递时,传用的是按比特位复制,因此形参也是个副本,只保留指针的底层const,而舍弃顶层const)
- 其他情况也是按值传递,形参同样也是一个副本。
三、数组和函数实参的推导规则
- 当函数模板为按值形参时(如T param):数组和函数类型均退化成指针类型。(如char*、void(*)(int, double)。
//由于数组到指针的退化规则,以下两个函数等价的,所以不能同时声明这两个同名函数。
void myFunc(int param[]);
void myFunc(int* param);
- 当函数模板为引用类型形参时(如T& param):则数组和函数分别被推导为数组引用和函数引用类型。特别值得注意,数组引用类型会包含数组元素类型及大小信息,如const char(&)[13]。而函数引用类型如void(&)(int, double)
数组引用的妙用:用于推导数组元素个数
35、C++虚拟继承的概念
类 A 派生出类 B 和类 C,类 D 继承自类 B 和类 C,这个时候类 A 中的成员变量和成员函数继承到类 D 中变成了两份,一份来自 A–>B–>D 这条路径,另一份来自 A–>C–>D 这条路径。
在一个派生类中保留间接基类的多份同名成员,虽然可以在不同的成员变量中分别存放不同的数据,但大多数情况下这是多余的:因为保留多份成员变量不仅占用较多的存储空间,还容易产生命名冲突。假如类 A 有一个成员变量 a,那么在类 D 中直接访问 a 就会产生歧义,编译器不知道它究竟来自 A -->B–>D 这条路径,还是来自 A–>C–>D 这条路径。下面是菱形继承的具体实现:
//间接基类A
class A{
protected:
int m_a;
};
//直接基类B
class B: public A{
protected:
int m_b;
};
//直接基类C
class C: public A{
protected:
int m_c;
};
//派生类D
class D: public B, public C{
public:
void seta(int a){ m_a = a; } //命名冲突
void setb(int b){ m_b = b; } //正确
void setc(int c){ m_c = c; } //正确
void setd(int d){ m_d = d; } //正确
private:
int m_d;
};
int main(){
D d;
return 0;
}
这段代码实现了上图所示的菱形继承,第 25 行代码试图直接访问成员变量 m_a,结果发生了错误,因为类 B 和类 C 中都有成员变量 m_a(从 A 类继承而来),编译器不知道选用哪一个,所以产生了歧义。
//我们可以在 m_a 的前面指明它具体来自哪个类
void seta(int a){ B::m_a = a; }
void seta(int a){ C::m_a = a; }
为了解决从为了解决多继承时的命名冲突和冗余数据问题,将基类设置为虚基类。这时从不同的路径继承过来的同名数据成员在内存中只有一个拷贝,同一个函数名也只有一个映射。这样不仅解决了二义性问题,也节省内存,避免数据不一致的问题。
定义:在多重继承下,一个基类可以在派生类中出现多次。(派生类对象中可能出现多个基类对象)在C++中,通过使用虚继承解决这类问题。虚继承是一种机制,类通过虚继承指出它希望共享其虚基类的状态。在虚继承下,对给定虚基类,无论该类在派生层次中作为虚基类出现多少次,只继承一个共享的基类子对象。共享的基类子对象称为虚基类。
//间接基类A
class A{
protected:
int m_a;
};
//直接基类B
class B: virtual public A{ //虚继承
protected:
int m_b;
};
//直接基类C
class C: virtual public A{ //虚继承
protected:
int m_c;
};
//派生类D
class D: public B, public C{
public:
void seta(int a){ m_a = a; } //正确
void setb(int b){ m_b = b; } //正确
void setc(int c){ m_c = c; } //正确
void setd(int d){ m_d = d; } //正确
private:
int m_d;
};
int main(){
D d;
return 0;
}
虚继承中的子类多了一张基类的虚函数表。
实例:
1. 虚继承的构造函数
在虚继承中,虚基类是由最终的派生类初始化的,换句话说,最终派生类的构造函数必须要调用虚基类的构造函数。对最终的派生类来说,虚基类是间接基类,而不是直接基类。这跟普通继承不同,在普通继承中,派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的。
2. 虚继承内存模型
对于普通继承,基类成员变量的偏移是固定的,不会随着继承层级的增加而改变,存取起来非常方便。而对于虚继承,恰恰和普通继承相反,大部分编译器会把基类成员变量放在派生类成员变量的后面,这样随着继承层级的增加,基类成员变量的偏移就会改变,就得通过其他方案来计算偏移量。下面我们来一步一步地分析虚继承时的对象内存模型。
class B: virtual public A;
36、设计模式
单例模式
简单点说,就是一个应用程序中,某个类的实例对象只有一个,你没有办法去new,因为构造器被private修饰的,一般通过getInstance()方法来获取它们的实例。getInstance()的返回值是一个对象的引用,并不是一个新的实例,所以不要错误的理解成多个对象
//饿汉式
//优点:简单
//缺点:可能会导致进程启动慢,且如果有多个单例类对象实例
class Singleton
{
public:
static Singleton* getInstance()
{
return &s_instance;
}
private:
Singleton(){};//构造函数私有
// C++11
Singleton(Singleton const&) = delete;
Singleton& operator=(Singleton const&) = delete;
static Singleton s_instance;
}
Singleton Singleton::instance;
int main()
{
Singleton *a1 = Singleton::getinstance();
cout << a1 << endl;
return 0;
}
//懒汉式
//有了上面饿汉式的经验,我们同样可以这么想:懒汉式,因为懒,所以唯一实例能拖就拖;一直等到有人要使用的时候,才不得不构造。
class Singleton
{
public:
static Singleton *getinstance()
{
if (instance == nullptr)
instance = new Singleton();
return instance;
}
private:
static Singleton *instance;
Singleton() {}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
Singleton *Singleton::instance = nullptr;
//线程安全版实现
class Singleton
{
public:
static Singleton* getinstance()
{
if(instance == nullptr)
{
mtx.lock();
if(instance == nullptr)
{
instance = new Singleton();
}
mtx.unlock();
}
return instance;
}
private:
static Singleton* instance;
Singleton(){}
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
static mutex mtx;
};
Singleton* Singleton::instance = nullptr;
mutex Singleton::mtx;
观察者模式
对象间一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新
装饰者模式
对已有的业务逻辑进一步的封装, 使其增加额外的功能,如java中的IO流就使用了装饰者模式,用户在使用的时候,可以任意组装,达到自己想要的效果。
适配器模式
将两种完全不同的事物联系到一起,就像现实生活中的变压器。假设一个手机充电器需要的电压是20V,但是正常的电压是220V,这时候就需要一个变压器,将220V的电压转换成20V的电压,这样,变压器就将20V的电压和手机联系起来了。
工厂模式
简单工厂模式:一个抽象的接口,多个抽象接口的实现类,一个工厂类,用来实例化抽象的接口
工厂方法模式:有四个角色,抽象工厂模式,具体工厂模式,抽象产品模式,具体产品模式。不再是由一个工厂类去实例化具体的产品,而是由抽象工厂的子类去实例化产品
#include <iostream>
#include <pthread.h>
using namespace std;
//产品类(抽象类,不能实例化)
class Product{
public:
Product(){};
virtual void show()=0; //纯虚函数
};
class productA:public Product{
public:
productA(){};
void show(){ cout << "product A create!" << endl; };
~productA(){};
};
class productB:public Product{
public:
productB(){};
void show(){ cout << "product B create!" << endl; };
~productB(){};
};
class simpleFactory{ // 工厂类
public:
simpleFactory(){};
Product* product(const string str){
if (str == "productA")
return (new productA());
if (str == "productB")
return (new productB());
return NULL;
};
};
int main(){
simpleFactory obj; // 创建工厂
Product* pro; // 创建产品
pro = obj.product("productA");
pro->show(); // product A create!
delete pro;
pro = obj.product("productB");
pro->show(); // product B create!
delete pro;
return 0;
}
//工厂模式为的就是代码解耦,如果我们不采用工厂模式,如果要创建产品A、B,我们通常做法是不是用switch...case语句?那麻烦了,代码耦合程度高,后期添加更多的产品进来,我们不是要添加更多的case吗?这样就太麻烦了,而且不符合设计模式中的开放封闭原则。
//为了进一步解耦,在简单工厂的基础上发展出了抽象工厂模式,即连工厂都抽象出来,实现了进一步代码解耦。代码如下:
#include <iostream>
#include <pthread.h>
using namespace std;
//产品类(抽象类,不能实例化)
class Product{
public:
Product(){}
virtual void show()=0; //纯虚函数
};
//产品A
class ProductA:public Product{
public:
ProductA(){}
void show(){ cout<<"product A create!"<<endl; };
};
//产品B
class ProductB:public Product{
public:
ProductB(){}
void show(){ cout<<"product B create!"<<endl; };
};
class Factory{//抽象类
public:
virtual Product* CreateProduct()=0;
};
class FactorA:public Factory{//工厂类A,只生产A产品
public:
Product* CreateProduct(){
return (new ProductA());
}
};
class FactorB:public Factory{//工厂类B,只生产B产品
public:
Product* CreateProduct(){
return (new ProductB());
}
};
int main(){
Product* _Product = nullptr;
auto MyFactoryA = new FactorA();
_Product = MyFactoryA->CreateProduct();// 调用产品A的工厂来生产A产品
_Product->show();
delete _Product;
auto MyFactoryB=new FactorB();
_Product=MyFactoryB->CreateProduct();// 调用产品B的工厂来生产B产品
_Product->show();
delete _Product;
getchar();
return 0;
}
PIMPL模式
悲观锁(Pessimistic Lock), 顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
乐观锁(Optimistic Lock), 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于write_condition机制的其实都是提供的乐观锁。
两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行retry,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。
37、文件系统
1. 什么是文件系统
文件系统是一种存储和组织计算机数据的方法。它负责对文件存储设备的空间进行组织和分配,负责文件存储并对存入的文件进行保护,以及检索的系统。如ext3和NTFS这两种文件系统对存储空间的划分、碎片的整理及检索的实现都不同,前者常用于linux的文件系统,后者常用于windows的文件系统。
所以,任意一种存储设备,如果想像linux文件系统一样,以树形结构查看文件,必须被格式化成某一种文件系统。所以我们会看到无论是硬盘还是闪存都会被格式化成某种文件系统(以分区为单位,当然有的分区不用来挂载文件系统,不必格式化,如存放内核的闪存分区)
2. 文件系统类型:
- 磁盘文件系统:FAT、 exFAT、NTFS、HFS、HFS+、ext2、ext3、ext4、ODS-5、btrfs等
- 闪存文件系统:尽管磁盘文件系统也能在闪存上使用,但闪存文件系统是闪存设备的首选,理由如下:
- 擦除区块:闪存的区块在重新写入前必须先进行擦除。擦除区块会占用相当可观的时间。因此,在设备空闲的时候擦除未使用的区块有助于提高速度。
- 随机访问:由于在磁盘上寻址有很大的延迟,磁盘文件系统有针对寻址的优化,以尽量避免寻址。但闪存没有寻址延迟。
- 写入平衡(Wear levelling):闪存中经常写入的区块往往容易损坏。闪存文件系统的设计可以使数据均匀地写到整个设备。日志文件系统具有闪存文件系统需要的特性,这类文件系统包括 JFFS2 和 YAFFS。也有为了避免日志频繁写入而导致闪存寿命衰减的非日志文件系统,如exFAT。
- 网络文件系统:它是一种将远程主机上的分区(目录)经网络挂载到本地系统的一种机制。
现在有一个问题:对于每一种文件系统,上层都是通过系统调用来操作的,甚至对于同一个操作系统,转到内核代码也会相同(例如linux系统,支持多种文件系统,上层调用一个系统调用,陷入到内核里的代码是一样的),那么,操作系统是如何知道是哪种文件系统的?这里不得不说一下文件系统的层次了,也就是调用的顺序。
3. 文件系统层次
首先,应用程序要操作一个磁盘上的文件(如系统调用open)
接着,会通过系统调用接口(见前面的博文,glibc的接口函数,它会执行swi指令)
然后,通过上面的swi指令陷入到内核,到达VFS层(VFS层是一个中间层,它可以让open()、read()、write()等系统调用不用关心底层的存储介质和文件系统类型就可以工作。如系统调用open陷入到内核后调用的sys_open就是属于这一层的函数)
再接着,VFS层会帮我们找到具体要操作文件所在的分区和文件系统类型
最后,是根据硬件驱动来驱动硬件来进行实际操作(硬件驱动之上还有一层,隐藏硬件驱动差异的,给它的上一层提供统一的接口)
38、explicit使用注意事项
- explicit 关键字只能用于类内部的构造函数声明上。
- explicit 关键字作用于单个参数的构造函数。
- 在C++中,explicit关键字用来修饰类的构造函数,被修饰的构造函数的类,不能发生相应的隐式类型转换。
#include <iostream>
using namespace std;
class Test1
{
public :
Test1(int num):n(num){}
private:
int n;
};
class Test2
{
public :
explicit Test2(int num):n(num){}
private:
int n;
};
int main()
{
Test1 t1 = 12;
Test2 t2(13);
Test2 t3 = 14;
return 0;
}
编译时,会指出 t3那一行error:无法从“int”转换为“Test2”。而t1却编译通过。注释掉t3那行,调试时,t1已被赋值成功。
在C++中,如果一个类有且只有一个参数的构造函数,C++允许一种特殊的声明类变量的方式。在这种情况下,可以直接将一个对应于构造函数参数类型的数据直接赋值给类变量,编译器在编译时会自动进行类型转换,将对应于构造函数参数类型的数据转换为类的对象。如果在构造函数前加上explicit修饰词,则会禁止这种自动转换,在这种情况下,即使将对应于构造函数参数类型的数据直接赋值给类变量,编译器也会报错。
39、C++11中的六大构造函数
一、构造函数
假如Myclass为一类,执行Myclass a[3],*p[2];语句时会自动调用该类构造函数几次?
答:3次
Myclass a[3],*p[2];
a[3]中有3个Myclass对象,定义时会各调用Myclass构造函数一次。
Myclass *p[2]只定义了两个指针,只是两个指针变量。
构造函数是特殊的成员函数,只要创建类类型的新对象,都要执行构造函数。构造函数的工作就是保证每个对象的数据成员具有合适的初始值。
构造函数与其他函数不同:构造函数函数与类同名,没有返回类型。
构造函数与其他函数相同:构造函数也有形参表(可为void)和函数体
构造函数构造类对象的顺序是:
- 内存分配,构造函数调用的时候隐式\显式的初始化各数据
- 执行构造函数的运行
1、构造函数初始化列表
-
A():a(0){}
- 我们使用构造函数初始化表示初始化数据成员,然而在没有使用初始化表的构造函数则在构造函数体中对数据成员赋值。
- 在我们编写类的时候,有些成员必须在构造函数初始化表中进行初始化。(没有默认构造函数的类类型成员,const或者引用类型成员)
- 在编写代码的时候,要注意的是:可以初始化const对象或者引用类型的对象,但不能对他们进行赋值。 也就是需要在我们执行构造函数函数体之前完成初始化工作,所以唯一的机会就是初始化表。从这一点可以看出初始化表的执行先于函数体。
- 在初始化表中,成员被初始化的次序不是你编写初始化表的次序,而是定义成员的次序。
- 初始化列表在初始化类类型的成员时,要指定实参并传递给成员类型的一个构造函数。
-
Salesitem():isbn(10,‘9’),units_sold(0),resenue(0,0){}
初始化表在什么时候必须使用
- 常量成员,因为常量只能初始化不能赋值,所以必须放在初始化列表里面
- 引用类型,引用必须在定义的时候初始化,并且不能重新赋值,所以也要写在初始化列表里面
- 没有默认构造函数的类类型,因为使用初始化列表可以不必调用默认构造函数来初始化,而是直接调用拷贝构造函数初始化。
初始化列表的优点: 主要是对于自定义类型,初始化列表是作用在函数体之前,他调用构造函数对对象进行初始化。
然而在函数体内,需要先调用构造函数,然后进行赋值,这样效率就不如初始化表。
2、默认实参构造函数
A(int i = 1):a(i),ca(i),ra(i){};
3、默认构造函数
合成默认构造函数: 当类中没有定义构造函数(注意是构造函数)的时候,编译器自动生成的函数。
但是我们不能过分依赖编译器,如果我们的类中有复合类型或者自定义类型成员,我们需要自己定义构造函数。
自定义的默认构造函数:
- A():a(0){}
- A(int i = 1):a(i){}
二、移动构造函数
移动构造函数的运行原理
A(A&&h):a(h.a)
{
h.a = nullptr;
}
可以看到,这个构造函数的参数不同,有两个&操作符, 移动构造函数接收的是“右值引用”的参数。
还要来说一下,这里h.a置为空,如果不这样做,h.a在移动构造函数结束时候执行析构函数会将我们偷来的内存析构掉。h.a会变成悬垂指针。
移动构造函数何时触发? 那就是临时对象(右值)。用到临时对象的时候就会执行移动语义。
这里要注意的是,异常发生的情况,要尽量保证移动构造函数 不发生异常,可以通过noexcept关键字,这里可以保证移动构造函数中抛出来的异常会直接调用terminate终止程序。
三、移动赋值操作符
他的原理跟移动构造函数相同
A & operator = (A&& h)
{
assert(this != &h);
a = nullptr;
a = move(h.a);
h.a = nullptr;
return *this;
}
四、复制(拷贝)构造函数
他是一种特殊的构造函数,具有单个形参,形参是对该类类型的引用。当定义一个新对象并用一个同类型的对象对它进行初始化时,将显式使用复制构造函数。当将该类型的对象传递给函数或从函数返回该类型的对象时,将隐式使用复制构造函数。
必须定义复制构造函数的情况:
- 类有一个或者多个数据成员是指针。
- 有成员表示在构造函数中分配的其他资源。另外的类在创建新对象时必须做一些特定的工作。
什么情况下调用:
- 程序中需要新建立一个对象,并用另一个同类的对象对它初始化:
box2=box1//box2(box1)
- 当函数的参数为类的对象时:
void fun(Box b) //形参是类的对象
- 函数的返回值是类的对象。
下面给出赋值构造函数的编写:
A(const A& h):a(h.a){}
如果不想让对象拷贝呢?
那就将复制构造函数声明为 private
五、赋值操作符
他跟构造函数一样,赋值操作符可以通过制定不同类型的右操作数而重载。
赋值和复制经常是一起使用的,这个要注意。
下面给出赋值操作符的写法:
A& operator = (const A& h)
{
assert(this != &h);
this->a = h.a;
return *this;
}
六、析构函数
是构造函数的互补,当对象超出作用域或动态分配的对象被删除时,将自动应用析构函数。析构函数可用于释放对象时构造或在对象的生命期中所获取的资源。不管类是否定义了自己的析构函数,编译器都会自动执行类中非static数据成员的析构函数。
析构函数的运行:
当对象引用或指针越界的时候不会执行析构函数,只有在删除指向动态分配对象的指针或实际对象超出作用域时才会调用析构函数。
合成析构函数:
编译器总是会合成一个析构函数,合成析构函数按对象创建时的逆序撤销每个非static成员。要注意的是,合成的析构函数不会删除指针成员所指向的对象。
什么时候调用析构函数:
- 变量在离开作用域时会被销毁
- 当一个对象被销毁时,他的成员被销毁
- 容器被销毁时(包括数组),其元素被销毁
- 动态分配的对象,对指向它的指针使用delete运算符时被销毁
- 对于临时对象,当创建完整表达式结束时被销毁
最后要注意的是:类如果需要析构函数,那么他肯定也需要复制构造函数和赋值操作符
40、关于创建对象的事
1. 只能在堆上创建对象
- 是动态建立类的对象,使用new操作符来完成。
- 将该类的构造函数和析构函数权限设为protected,(可以让该类可以被继承),也可设置为private,然后定义两个static 函数来调用new ,delete 来创建和销毁对象。但在继承中无法实现,必须调用一个成员函数来调用delete this
class A
{
protected:
A()
{};
~A()
{};
static A* Create()
{
return new A();
}
static void Destory(A* p)
{
delete p;
p = NULL;
}
};
2. 只能在栈上创建对象
- 只能在栈上创建的对象的话,就是不能调用new 操作符,所以可以将operator new 和operator delete 设置为私有的。
class AA
{
private:
void* operator new(size_t)
{};
void operator delete(void*)
{};
public:
AA()
{
cout << "AA()" << endl;
}
~AA()
{
cout << "~AA()" << endl;
}
};
3. 只能创建一个对象
- 在类中创建一个静态变量Count,用来限制可创建的实例的数量。
class singleclass
{
public:
static singleclass* getsingleclass()
{
if (count > 0)
{
count--;
return new singleclass();
}
else
{
return NULL;
}
}
private:
static int count;
singleclass(){};
};
41、memcpy和memmove
1. memcpy
一般来说,memcpy的实现非常简单,只需要顺序的循环,把字节一个一个从src拷贝到dest就行:
#include <stddef.h> /* size_t */
void *memcpy(void *dest, const void *src, size_t n)
{
char *dp = dest;
const char *sp = src;
while (n--)
*dp++ = *sp++;
return dest;
}
2. memmove
memmove会对拷贝的数据作检查,确保内存没有覆盖,如果发现会覆盖数据,简单的实现是调转开始拷贝的位置,从尾部开始拷贝:
#include <stddef.h> /* for size_t */
void *memmove(void *dest, const void *src, size_t n)
{
unsigned char *pd = dest;
const unsigned char *ps = src;
if (__np_anyptrlt(ps, pd))
for (pd += n, ps += n; n--;)
*--pd = *--ps;
else
while(n--)
*pd++ = *ps++;
return dest;
}
42、重载运算符
重载运算符本质上是函数。
如果一个运算符是一个成员函数,则其左侧运算对象就绑定到隐式的this参数。
赋值运算符通常返回一个指向其左侧运算对象的引用。Foo& operator = (const Foo&)
重载运算符应该继承而不是违背其内置版本的定义
43、OOP
核心思想:数据抽象、继承、动态绑定。
1、任何构造函数之外的非静态函数都可以是虚函数。关键字virtual
只能出现在类内部而不能用于类外部的函数定义;
2、如果派生类没有覆盖其基类中的某个虚函数,则该函数的行为类似于其他的普通成员,派生类会直接继承其在基类的版本;
3、首先初始化基类的部分,然后按声明的顺序依次初始化派生类的成员;
Bulk_quote(const std::string& book , double p ,
std::size_t qty , double disc) :Quote(book,p) ,
min_qty(qty) , discount(disc) {};
4、如果基类定义了一个静态成员,则整个继承体系中只存在该成员的唯一定义,加入该成员是可访问的,则可以通过派生类去使用它;
5、在类名后面加final
可以防止该类被继承;
6、一个派生类如果要去覆盖某个继承而来的虚函数,则他的形参类型必须与他覆盖的的基类函数完全一致。同样,派生类中虚函数的返回类型也必须与基类函数匹配。但是当虚函数返回类型是类本身的指针或引用时,上述规则无效。
7、如果我们使用override
标记某个函数,但该函数并没有覆盖已存在的虚函数,此时编译器将报错;
8、含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类;
9、派生类的成员或者友元只能通过派生类对象来访问基类受保护成员;
10、基类、友元、派生类访问权限:
public成员 | protected成员 | private成员 | |
---|---|---|---|
基类及其友元 | 有 | 有 | 有 |
派生类及其友元 | 有 | 有 | 无 |
类用户 | 有 | 无 | 无 |
具体来说就是:
1)基类本身及其友元对基类中任何成员都有访问权限;
2)派生类及派生类的友元可以通过派生类的对象访问基类中受保护的成员,对private成员无访问权限;
3)类用户(由基类定义的对象)只能访问基类中的public成员。
11、我们通常把基类的析构函数定义成虚函数以保证执行正确的析构函数版本;
12、虚析构函数将阻止合成移动操作;
44、泛型编程
模板含义
1、模板是实现代码重用机制的一种工具,它可以实现类型参数化,即把类型定义为参数,从而实现真正的代码可重用性;
2、模板分为两类,一个是函数模板一个是类模板。
什么是函数模板?
建立一个通用函数模板
template<typename T>
T max(T a , T b , T c)
{
if(b > a)
a = b;
if(c > a)
a = c;
return a;
}
什么是类模板?
c++的类模板为生成通用的类声明提供一种更好的方法,模板提供参数化类型,即能够将类型名作为参数传递给接收方来建立类或者函数
当我们在类模板外定义一个成员模板时,必须同时为类模板和成员模板提供模板参数列表。类模板参数在前,成员模板参数在后。
template <typename T> //类的类型参数
template <typename It> //构造函数的类型参数
Blob<T>::Blob(It b , It e) : data(std::make_shared<std::vector<T>>(b,e)) { };
45、lambda表达式(匿名函数)
lambda表达式具体形式:
[capture list](parameter list) -> return type{function body}
其中capture list(捕获列表)是一个lambda所在函数中定义的局部变量的列表(通常为空),parameter list和function body与任何普通函数一样,分别表示返回类型、参数列表和函数体。与普通函数不同,lambda通常使用尾置返回来指定类型。
我们可以忽略参数列表和返回类型,但是必须永远包含捕获列表和函数体。与普通函数不同,lambda不能有默认参数,一个lambda调用的实参必须与形参数量相同
// 长度排序
sort(words.begin() , words.end() ,
[](const string& a , const string& b)
{ return a.size() < b.size();})
1. 捕获与返回
Lambda捕获列表 | |
---|---|
[] | 空捕获列表,Lambda不能使用所在函数中的变量。 |
[names] | names是一个逗号分隔的名字列表,这些名字都是Lambda所在函数的局部变量。默认情况下,这些变量会被拷贝,然后按值传递,名字前面如果使用了&,则按引用传递 |
[&] | 隐式捕获列表,Lambda体内使用的局部变量都按引用方式传递 |
[=] | 隐式捕获列表,Lanbda体内使用的局部变量都按值传递 |
[&,identifier_list] | identifier_list是一个逗号分隔的列表,包含0个或多个来自所在函数的变量,这些变量采用值捕获的方式,其他变量则被隐式捕获,采用引用方式传递,identifier_list中的名字前面不能使用&。 |
[=,identifier_list] | identifier_list中的变量采用引用方式捕获,而被隐式捕获的变量都采用按值传递的方式捕获。identifier_list中的名字不能包含this,且这些名字面前必须使用&。 |
46、C++ 14/17/20新特性
1. C++14的新特性
1. 返回值类型推导
auto func(int i)
{
return i;
}
注意
- 如果函数里面有多个return语句,他们必须返回相同的类型,否则编译失败
- 如果人return语句返回初始化列表,返回值类型推导也会失败
return {1,2,3}
- 如果函数是虚函数,也不能使用类型推导
- 返回类型推导可以使用在前向声明中,但是在使用他们之前,翻译单元中必须能够得到函数的定义
auto f();
auto f() { return 42;}
int main()
{
cout<<f()<<endl;
}
- 返回类型可以使用在递归函数中,但是递归调用必须以至少一个返回语句作为先导,以便编译器推导出返回类型。
auto sum(int i)
{
if(i == 1)
return i; //return int
else
return sum(i - 1) + i; // ok
}
2. lanbda参数auto
auto f = [](auto a){ return a;}
cout<< f(1) <<endl;
cout<< f(2.3f) << endl;
3. 变量模板
template<class T>
constexpr T pi = T(3.1415926535897932385L);
int main() {
cout << pi<int> << endl; // 3
cout << pi<double> << endl; // 3.14159
return 0;
}
3. 别名变量
template<typename T, typename U>struct A {
T t;
U u;
};
template<typename T>
using B = A<T, int>;
int main() {
B<double> b;
b.t = 10;
b.u = 20;
cout << b.t << endl;
cout << b.u << endl;
return 0;
}
4. constexpr
C++14 相较于C++11对constexpr减少了一些限制:
- C++11中constexpr函数可以使用递归,在C++14中可以使用局部变量和循环
- C++11中constexpr函数必须把所有东西都放在一个单独的return中,而14就没有这个限制
5. [deprecated]标记
C++14中增加了deprecated标记,修饰类、变、函数等,当程序中使用到了被其修饰的代码时,编译时被产生警告,用户提示开发者该标记修饰的内容将来可能会被丢弃,尽量不要使用。
6. 二进制字面量与整形字面量分隔符
int a = 0b0001'0011'1010;
double b = 3.14'1234'1234'1234;
7. std::make_unique
我们都知道C++11中有std::make_shared,却没有std::make_unique,在C++14已经改善。
struct A {};
std::unique_ptr<A> ptr = std::make_unique<A>();
8. std::shared_timed_mutex与std::shared_lock
C++14通过std::shared_timed_mutex和std::shared_lock来实现读写锁,保证多个线程可以同时读,但是写线程必须独立运行,写操作不可以同时和读操作一起进行。
2. C++17新特性
1. 构造函数模板推导
C++17之前构造一个模板类需要指明类型,C++17就不需要
pair<int, double> p(1, 2.2); // before c++17
pair p(1, 2.2); // c++17 自动推导
vector v = {1, 2, 3}; // c++17
2. 结构化绑定
结构化绑定详解
结构化绑定:通过对象的元素或成员初始化多个实体。
结构化绑定之前我们遍历给定的是无意义的elem。
for (const auto& elem : mymap) {
std::cout << elem.first << ": " << elem.second << std::endl;
}
有了结构体绑定之后,我们只需要[key, val]。
for (const auto& [key, val] : mymap) {
std::cout << key << ": " << val << std::endl;
}
3. if-switch初始化
//C++11
int a = GetValue();
if (a < 101) {
cout << a;
}
//C++17
if (int a = GetValue()); a < 101) {
cout << a;
}
string str = "Hi World";
if (auto [pos, size] = pair(str.find("Hi"), str.size()); pos != string::npos) {
std::cout << pos << " Hello, size is " << size;
}
4. 内联变量
C++17前只有内联函数,现在有了内联变量,我们印象中C++类的静态成员变量在头文件中是不能初始化的,但是有了内联变量,就可以达到此目的:
// header file
struct A {
static const int value;
};
inline int const A::value = 10;
// ==========或者========
struct A {
inline static const int value = 10;
}
5. 折叠表达式
template <typename ... Ts>
auto sum(Ts ... ts) {
return (ts + ...);
}
int a {sum(1, 2, 3, 4, 5)}; // 15
std::string a{"hello "};
std::string b{"world"};
cout << sum(a, b) << endl; // hello world
6. constexpr lambda表达式
C++17前lambda表达式只能在运行时使用,C++17引入了constexpr lambda表达式,可以用于在编译期进行计算。
int main() { // c++17可编译
constexpr auto lamb = [] (int n) { return n * n; };
static_assert(lamb(3) == 9, "a");
}
constexpr函数有如下限制:
函数体不能包含汇编语句、goto语句、label、try块、静态变量、线程局部存储、没有初始化的普通变量,不能动态分配内存,不能有new delete等,不能虚函数。
7. namespace嵌套
namespace A {
namespace B {
namespace C {
void func();
}
}
}
// c++17,更方便更舒适
namespace A::B::C {
void func();)
}
8. std::variant
3. C++20新特性
1. 比较运算<=>
对于 (a <=> b),如果a > b ,则运算结果>0,如果a < b,则运算结果<0,如果a==b,则运算结果等于0,注意下,运算符的结果类型会根据a和b的类型来决定,所以我们平时使用时候最好直接用auto,方便快捷。
2. for循环括号里面可以初始化
for (auto n = v.size(); auto i : v) // the init-statement (C++20)
std::cout << --n + i << ' ';
3. 分支预测
[[likely]]和[[unlikely]]:在分支预测时,用于告诉编译器哪个分支更容易被执行,哪个不容易执行,方便编译器做优化。
constexpr long long fact(long long n) noexcept {
if (n > 1) [[likely]]
return n * fact(n - 1);
else [[unlikely]]
return 1;
}
4.lambda表达式的捕获
C++20之前[=]会隐式捕获this,而C++20需要显式捕获,这样[=, this]
struct S2 { void f(int i); };
void S2::f(int i)
{
[=]{}; // OK: by-copy capture default
[=, &i]{}; // OK: by-copy capture, except i is captured by reference
[=, *this]{}; // until C++17: Error: invalid syntax
// since c++17: OK: captures the enclosing S2 by copy
[=, this] {}; // until C++20: Error: this when = is the default
// since C++20: OK, same as [=]
}
Modules
// helloworld.cpp
export module helloworld; // module declaration
import <iostream>; // import declaration
export void hello() { // export declaration
std::cout << "Hello world!\n";
}
// main.cpp
import helloworld; // import declaration
int main() {
hello();
}
47、面试题
1、写一个“标准”宏MIN,这个宏输入两个参数并返回较小的一个。另外,当你写下面的代码时会发生什么事?
least = MIN(*p++, b)
ans:
#define MIN(A,B) ((A) <= (B) ? (A) : (B))
MIN(*p++, b)会产生宏的副作用
宏定义可以实现类似于函数的功能,但是它终归不是函数,而宏定义中括弧中的“参数”也不是真的参数,在宏展开的时候对“参数”进行的是一对一的替换。
程序员对宏定义的使用要非常小心,特别要注意两个问题:
1)谨慎地将宏定义中的“参数”和整个宏用用括弧括起来。所以,严格地讲,下述解答:
#define MIN(A,B) (A) <= (B) ? (A) : (B)
#define MIN(A,B) (A <= B ? A : B )
3*MIN(A,B) -> 3*(A)<=(B)?(A):(B)//发生歧义
都应判0分;
2)防止宏的副作用。
宏定义#define MIN(A,B) ((A) <= (B) ? (A) : (B))对MIN(*p++, b)的作用结果是:
((*p++) <= (b) ? (*p++) : (b))
这个表达式会产生副作用,指针p会作2次++自增操作。
除此之外,另一个应该判0分的解答是:
#define MIN(A,B) ((A) <= (B) ? (A) : (B));
这个解答在宏定义的后面加“;”,显示编写者对宏的概念模糊不清,只能被无情地判0分并被面试官淘汰。
2、C语言的编译链接过程?
源代码–>预编译–>编译–>优化–>汇编–>链接–>可执行文件
- 预编译
- 将所有的#define删除,并且展开所有的宏定义
- 处理所有的条件预编译指令,如#if、#ifdef
- 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置。
- 过滤所有的注释
- 添加行号和文件名标识。
- 编译阶段
- 汇编过程
汇编过程实际上指把汇编语言代码翻译成目标机器指令的过程。对于被翻译系统处理的每一个C语言源程序,都将最终经过这一处理而得到相应的目标文件。目标文件中所存放的也就是与源程序等效的目标的机器语言代码。 .o目标文件 - 链接阶段
链接程序的主要工作就是 将有关的目标文件彼此相连接,也即将在一个文件中引用的符号同该符号在另外一个文件中的定义连接起来,使得所有的这些目标文件成为一个能够操作系统装入执行的统一整体。
扩展:静态链接和动态链接?
- 静态链接:是在链接的时候就已经把要调用的函数或者过程链接到了生成的可执行文件中,就算你在去把静态库删除也不会影响可执行程序的执行;生成的静态链接库,Windows下以.lib为后缀,Linux下以.a为后缀。
- 动态链接:是在链接的时候没有把调用的函数代码链接进去,而是在执行的过程中,再去找要链接的函数,生成的可执行文件中没有函数代码,只包含函数的重定位信息,所以当你删除动态库时,可执行程序就不能运行。生成的动态链接库,Windows下以.dll为后缀,Linux下以.so为后缀。
区别:
- 静态链接是将各个模块的obj和库链接成一个完整的可执行程序;而动态链接是程序在运行的时候寻找动态库的函数符号(重定位)
- 静态链接运行快、可独立运行;动态链接运行较慢(事实上,动态库被广泛使用,这个缺点可以忽略)、不可独立运行。
- 静态链接浪费空间,存在多个副本,同一个函数的多次调用会被多次链接进可执行程序,当库和模块修改时,main也需要重编译;动态链接节省空间,相同的函数只有一份,当库和模块修改时,main不需要重编译。
3、请你说一说 OOP 的设计模式的五项原则
面向对象设计五大原则
ans:
1、单一职责原则
单一职责有2个含义,一个是避免相同的职责分散到不同的类中,另一个是避免一个类承担太多职责。减少类的耦合,提高类的复用性。
2、接口隔离原则
表明客户端不应该被强迫实现一些他们不会使用的接口,应该把接口按方法分组,然后用多个接口代替它,每个接口服务于一个子模块。简单说,就是使用多个专门的接口比使用单个接口好很多。
该原则观点如下:
1)一个类对另外一个类的依赖性应当是建立在最小的接口上
2)客户端程序不应该依赖它不需要的接口方法。
3、开放-封闭原则
open模块的行为必须是开放的、支持扩展的,而不是僵化的。
closed在对模块的功能进行扩展时,不应该影响或大规模影响已有的程序模块。一句话概括:一个模块在扩展性方面应该是开放的而在更改性方面应该是封闭的。
核心思想就是对抽象编程,而不对具体编程。
4、替换原则
子类型必须能够替换掉他们的父类型、并出现在父类能够出现的任何地方。
主要针对继承的设计原则
1)父类的方法都要在子类中实现或者重写,并且派生类只实现其抽象类中生命的方法,而不应当给出多余的,方法定义或实现。
2)在客户端程序中只应该使用父类对象而不应当直接使用子类对象,这样可以实现运行期间绑定。
5、依赖倒置原则
上层模块不应该依赖于下层模块,他们共同依赖于一个抽象,即:父类不能依赖子类,他们都要依赖抽象类。
抽象不能依赖于具体,具体应该要依赖于抽象。
4、说一下C++和C的区别
ans:
c是面向过程的,c++是面向对象的;
1、兼容c;
2、多了OOP面向对象。有继承、封装、多态三大特点,有虚函数、虚表指针。
3、泛型编程、模板、STL。
5、说一说C++中四种 cast 转换
ans:
1、const_cast用于将const变量转为非const
2、static_cast用的最多,对于各种隐式转换,非const转const,void*转指针等, static_cast能用于多态想上转化,如果向下转能成功但是不安全,结果未知;
3、dynamic_cast用于动态类型转换。只能用于含有虚函数的类,用于类层次间的向上和向下转化。只能转指针或引用。向下转化时,如果是非法的对于指针返回NULL,对于引用抛异常。要深入了解内部转换的原理。
4、reinterpret_cast几乎什么都可以转,比如将int转指针,可能会出问题,尽量少用;
6、为什么不使用C的强制转换?
ans:
C的强制转换表面上看起来功能强大什么都能转,但是转化不够明确,不能进行错误检查,容易出错。
7、拷贝构造函数、赋值构造函数、析构函数
ans:
- 作用:
拷贝:用原对象创建并初始化新对象;
赋值:用原对象对已有对象进行赋值;
析构函数:释放对象等作用。
注意::拷贝构造函数中创建的对象是一个实实在在的新开辟内存区域的对象,而并不是一个指向原对象的指针。
- 声明方式:
拷贝: MyClass(const MyClass& mycla);
赋值: MyClass&MyClass::operator= (const MyClass & mycla);
析构函数 ~MyClass();
- 注意事项:
- 拷贝构造函数也是构造函数,所以没有返回值。拷贝构造函数的形参不限制为const,但是必须是一个引用,以传地址方式传递参数,否则导致拷贝构造函数无穷的递归下去,指针也不行,本质还是传值。
- 赋值构造函数是通过重载赋值操作符实现的,它接受的参数和返回值都是指向类对象的引用变量。
- 区别与共同点:
注意,拷贝构造函数和赋值构造函数的调用都是发生在有赋值运算符‘=’存在的时候,只是有一区别:
拷贝构造函数调用发生在对象还没有创建且需要创建时,如:MyClass obj1; MyClass obj2=obj1或MyClass obj2(obj1);
赋值构造函数仅发生在对象已经执行过构造函数,即已经创建的情况下,如:
MyClass obj1; MyClass obj2; obj2=obj1;
区别:拷贝构造函数就像变量初始化,赋值构造函数就如同变量赋值。前者是在用原对象创建新对象,而后者是在用原对象对已有对象进行赋值。
共同点:拷贝构造函数和赋值构造函数都是浅拷贝,所以遇到类成员含有指针变量时,类自动生成的默认拷贝构造函数和默认赋值构造函数就不灵了。因为其只可以将指针变量拷贝给新对象,而指针成员指向的还是同一内存区域,容易产生:冲突、野指针、多次释放等问题。解决方法就是自己定义具有深拷贝能力的拷贝构造函数或者赋值构造函数。
- 拷贝与赋值构造函数内在原理(m_data是String类成员):
// 拷贝构造函数
String::String(const String &other)
{
//允许操作other 的私有成员m_data
int length = strlen(other.m_data);
m_data = new char[length+1];(1)//开辟新对象内存
strcpy(m_data, other.m_data);(2)//复制内容到新对象
}
// 赋值函数
String & String::operator =(const String &other)
{
//(1) 检查自赋值
if(this == &other)
return *this;
//(2) 释放原有的内存资源
delete [] m_data;
//(3)分配新的内存资源,并复制内容
int length = strlen(other.m_data);
m_data = new char[length+1];
strcpy(m_data, other.m_data);
//(4)返回本对象的引用
return *this;
}
- 拷贝和赋值构造函数都是新开辟内存,然后复制内容进来;
- 赋值构造函数一定要最先检测本操作是否为自己给自己赋值,若是就会直接返回本身。若直接从第(2)步开始就会释放掉自身,从而造成第(3)步strcpy中的other找不到内存数据,从而使得赋值操作失败。
6. 将类中的析构函数设为私有,类外就不可以自动调用销毁对象,所以只可以通过new创建对象,手动delete销毁。
8、数组和指针区别
- 概念:
- 数组:数组是储存多个同类型元素的集合,数组名是首元素地址
- 指针:指针是一个变量,它存放的是内存地址,指针名就是指向内存的地址。
- 区别:
- 赋值:同类型指针可以相互赋值,数组只能一个一个的拷贝;
- 储存方式:数组在内存中是连续存放的,数组可以根据下标进行访问,数组的储存空间不是在静态存储区就是在栈上。指针本身是一个变量,作为局部变量时储存在栈上。
- 在使用sizeof时,可以使用sizeof(数组名)/sizeof(数据类型)计算数组内存大小。指针的大小是固定的(不论数据类型),32位机时4字节,84位机时8字节。
9、说说使用指针需要注意什么?
- 定义指针时,先初始化为NULL。
- 用malloc申请内存之后,应该立即检查指针值是否为NULL。防止使用指针值为NULL的内存。在现行C++标准中,如C++11,使用new申请内存后不用判空,因为发生错误将抛出异常;
- 不要忘记为数组和动态内存赋初值。防止将未被初始化的内存作为右值使用;
- 避免数字或指针的下标越界;
- 动态内存的申请与释放必须配对,防止内存泄漏;
- 用free或delete释放了内存之后,立即将指针设置为NULL,防止“野指针”。
10、你怎么理解C语言和C++的区别?
- C语言是面向过程的,C++是面向对象的。
- C++兼容C语言
- C语言有一些不安全的语言特性,如指针使用的潜在危险、强制转换的不确定性、内存泄露等。而C++对此增加了不少新特性来改善安全性,如const常量、引用、cast转换、智能指针、try—catch等等;
- C++可复用性高,C++引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL(Standard Template Library)。STL的一个重要特点是数据结构和算法的分离,其体现了泛型化程序设计的思想。C++的STL库相对于C语言的函数库更灵活、更通用。
11、简述下C++的特点
- C++在C语言基础上引入了面向对象的机制,同时也兼容C语言。
- C++有三大特性(1)封装。(2)继承。(3)多态;
- C++语言编写出的程序结构清晰、易于扩充,程序可读性好。
- C++生成的代码质量高,运行效率高,仅比汇编语言慢10%~20%;
- C++更加安全,增加了const常量、引用、四类cast转换(static_cast、dynamic_cast、const_cast、reinterpret_cast)、智能指针、try—catch等等;
- C++可复用性高,C++引入了模板的概念,后面在此基础上,实现了方便开发的标准模板库STL(Standard Template Library)。
- 同时,C++是不断在发展的语言。C++后续版本更是发展了不少新特性,如C++11中引入了nullptr、auto变量、Lambda匿名函数、右值引用、智能指针。
12、请你说说什么是宏?为什么要少使用宏?C++有什么解决方案?
#define
命令是一个宏命令,它用来将一个标识符定义为一个字符串,该标识符被称为宏名,被定义的字符串称为替换文本。
该命令有两种格式:一种是不带参数的宏定义,另一种是带参数的宏定义。
- 由程序编译的四个过程,知道宏是在预编译阶段被展开的。在预编译阶段是不会进行语法检查、语义分析的,宏被暴力替换,正是因为如此,如果不注意细节,宏的使用很容易出现问题。比如在表达式中忘记加括号等问题。
- 正因为如此,在C++中为了安全性,我们就要少用宏。
不带参数的宏命令我们可以用常量const来替代,比如const int PI = 3.1415,可以起到同样的效果,而且还比宏安全,因为这条语句会在编译阶段进行语法检查。 - 而带参数的宏命令有点类似函数的功能,在C++中可以使用内联函数或模板来替代,内联函数与宏命令功能相似,是在调用函数的地方,用函数体直接替换。但是内联函数比宏命令安全,因为内联函数的替换发生在编译阶段,同样会进行语法检查、语义分析等,而宏命令发生在预编译阶段,属于暴力替换,并不安全。
13、请你说说内联函数,为什么使用内联函数?需要注意什么?
-
C++ 内联函数是通常与类一起使用。如果一个函数是内联的,那么在编译时,编译器会把该函数的代码副本放置在每个调用该函数的地方。
如果想把一个函数定义为内联函数,则需要在函数名前面放置关键字 inline。 -
为什么使用内联函数?
函数调用是有调用开销的,执行速度要慢很多,调用函数要先保存寄存器,返回时再恢复,复制实参等等。
如果本身函数体很简单,那么函数调用的开销将远大于函数体执行的开销。为了减少这种开销,我们才使用内联函数。 -
内联函数使用的条件
-
以下情况不宜使用内联:
(1)如果函数体内的代码比较长,使用内联将导致内存消耗代价较高。
(2)如果函数体内出现循环,那么执行函数体内代码的时间要比函数调用的开销大。
-
内联不是什么时候都能展开的,一个好的编译器将会根据函数的定义体,自动地取消不符合要求的内联。
14、内联函数和宏的区别?
- 宏定义不是函数,宏定义只是简单的替换,不进行类型检查(返回值,参数列表等)。
- 内联函数是函数,内联函数在编译时在插入处展开代码,不需要调用执行,但是不能有复杂的条件语句。
- 无法代替宏定义作为卫哨防止被其他文件重复包含。
15、字节对齐
1. 什么是字节对齐?
#include<stdio.h>
#include<stdint.h>
struct test
{
int a;
char b;
int c;
short d;
};
typedef union
{
double i;
int k[5];
char c;
}DATE;
int main(int argc,char *argv)
{
/*在32位和64位的机器上,size_t的大小不同*/
printf("the size of struct test is %zu\n",sizeof(struct test));
return 0;
}
结构体
未对齐时:
0~3 | 4 | 5~9 | 10~11 |
---|---|---|---|
a | b | c | d |
对齐时:
0~3 | 4 | 5~7 | 8~11 | 12 ~ 13 | 14 ~ 15 |
---|---|---|---|---|---|
a | b | 填充内容 | c | d | 填充 |
联合
union中最大的变量类型是int[5],所以占用20个字节,大小是20,由于double占8字节,要进行8字节对齐,所以内存空间是8的倍数,最后所占空间是24。
总的来说,字节对齐有以下准则:
- 结构体变量的首地址能够被其对齐字节数大小所整除。
- 结构体每个成员相对结构体首地址的偏移都是成员大小的整数倍,如不满足,对前一个成员填充字节以满足。
- 结构体的总大小为结构体对最大成员大小的整数倍,如不满足,最后填充字节以满足。
注意:64位机器指针大小为8字节,32为机器为4字节
2. 为什么要字节对齐?
- 根本原因:影响CPU访问效率,无论数据是否对齐,大多数计算机还是能够正确工作,而且从前面可以看到,结构体test本来只需要11字节的空间,最后却占用了16字节,很明显浪费了空间,那么为什么还要进行字节对齐呢?最重要的考虑是提高内存系统性能前面我们也说到,计算机每次读写一个字节块,例如,假设计算机总是从内存中取8个字节,如果一个double数据的地址对齐成8的倍数,那么一个内存操作就可以读或者写,但是如果这个double数据的地址没有对齐,数据就可能被放在两个8字节块中,那么我们可能需要执行两次内存访问,才能读写完成。显然在这样的情况下,是低效的。所以需要字节对齐来提高内存系统性能。在有些处理器中,如果需要未对齐的数据,可能不能够正确工作甚至crash,。
- 一些系统对对齐要求非常严格,比如sparc系统,如果取未对齐的数据会发生错误,而在x86上就不会出现错误,只是效率下降。
- 各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。比如有些平台每次读都是从偶地址开始
#pragma pack () 取消指定对齐,恢复缺省对齐
#pragma pack (2) 2字节对齐
联合体赋值问题
给联合中的成员赋值时,只会对这个成员所属的数据类型所占内存空间的大小覆盖成后来的这个值,而不会影响其他位置的值。
- 成员为两个int类型变量,第一次给a赋值为20,它占4个字节,第二次给b赋值10,会把10(高位补零)覆盖int类型所占的4个字节(32位),所以最后a和b都是10。
- 成员a为int类型,b为short类型,第一次给a赋值100000,二进制为0000 0000 0000 0001 1000 0110 1010 0000,第二次给b赋值,由于short只占2个字节,所以只会覆盖16位二进制,0000 0000 0000 1010,最后的结果是0000 0000 0000 0001 0000 0000 0000 1010(从低位覆盖),所以输出结果为65546。
- 由于给int类型的a赋值的二进制展开只有在低两个字节内出现1,所以给short类型的b赋值时,会把a全部覆盖掉,所以两个的结果都为10。
16、说说内联函数和函数的区别,内联函数的作用。
- 内联函数比函数多了关键字inline;
- 内联函数避免函数调用的开销;
- 普通函数调用时需要寻址,内联函数没有这种要求;
- 内联函数有一定限制,不能过于复杂,一般不能有复杂的循环语句;
内联函数的作用:内联函数在调用时,是将调用表达式用内联函数体来替换。避免函数调用的开销。
17、说说const int *a, int const *a, const int a, int *const a, const int *const a分别是什么,有什么特点。
1. const int a; //指的是a是一个常量,不允许修改。
2. const int *a; //a指针所指向的内存里的值不变,即(*a)不变
3. int const *a; //同const int *a;
4. int *const a; //a指针所指向的内存地址不变,即a不变
5. const int *const a; //都不变,即(*a)不变,a也不变
18、说说静态局部变量,全局变量,局部变量的特点,以及使用场景
- 首先从作用域考虑:C++里作用域可分为6种:全局,局部,类,语句,命名空间和文件作用域。
- 全局变量:全局作用域,可以通过extern作用于其他非定义的源文件。
- 静态全局变量 :全局作用域+文件作用域,所以无法在其他文件中使用。
- 局部变量:局部作用域,比如函数的参数,函数内的局部变量等等。
- 静态局部变量 :局部作用域,只被初始化一次,直到程序结束。
- 从所在空间考虑:除了局部变量在栈上外,其他都在静态存储区。因为静态变量都在静态存储区,所以下次调用函数的时候还是能取到原来的值。
- 生命周期:局部变量在栈上,出了作用域就回收内存;而全局变量、静态全局变量、静态局部变量都在静态存储区,直到程序结束才会回收内存。
- 使用场景:从它们各自特点就可以看出各自的应用场景,不再赘述。
19、静态变量什么时候初始化?
对于C语言的全局和静态变量,初始化发生在任何代码执行之前,属于编译期初始化。
而C++标准规定:全局或静态对象当且仅当对象首次用到时才进行构造。
20、static关键字的作用
1. 修饰全局变量。该变量只能在该文件中使用,其他文件不可访问,存放在静态存储区。
2. 修饰局部变量。该变量作用域只在该局部函数里,出了函数静态局部变量不会被释放,如果未初始化默认会初始化为0。存放在静态存储区。
3. 修饰静态函数。在函数返回类型前加static,函数就定义为静态函数。函数的定义和声明在默认情况下都是extern的,但静态函数只是在声明他的文件当中可见,不能被其他文件所用。
4. 修饰成员变量,该变量为所有类对象共享,不需要this指针,并且不能和const一起使用,因为const需要this指针。
5. 修饰成员函数,用命名空间表示。
- 定义静态函数或者全局变量:当我们同时编译多个文件时,在函数返回类型或全局变量前加上static关键字,函数或全局变量即被定义为静态函数或静态全局变量。静态函数或静态全局变量只能在本源文件中使用。这就是static的隐藏属性。
- static的第二个作用是保持变量内容的持久:在变量前面加上static关键字。初始化的静态变量会在数据段分配内存,未初始化的静态变量会在BSS段分配内存。直到程序结束,静态变量始终维持前值。只不过全局静态变量和局部静态变量的作用域不一样。
- static 的第三个作用是默认初始化为 0: 全局变量也具备这一属性,因为全局变量也存储在静态数据区。在静态数据区,内存中所有的字节默认值都是 0x00 。
最后对 static 的三条基本作用做一句话总结。首先 static 的最主要功能是隐藏,其次因为 static 变量存放在静态存储区,所以它具备持久性和默认值0。 - 在c++中,static关键字可以用于定义类中的静态成员变量:使用静态数据成员,它既可以被当成全局变量那样去存储,但又被隐藏在类的内部。类中的static静态数据成员拥有一块单独的存储区,而不管创建了多少个该类的对象。所有这些对象的静态数据成员都共享这一块静态存储空间。
- 在c++中,static关键字可以用于定义类中的静态成员函数:与静态成员变量类似,类里面同样可以定义静态成员函数。只需要在函数前加上关键字static即可。如静态成员函数也是类的一部分,而不是对象的一部分。所有这些对象的静态数据成员都共享这一块静态存储空间。
21、为什么静态成员函数不能访问非静态成员
静态成员函数不属于任何一个对象,因此C++规定静态成员函数没有this指针。既然他没有指向某一个对象,也就无法对一个对象中的非静态成员进行访问。
22、静态成员函数和普通成员函数的区别
静态成员函数没有this指针,只能访问静态成员;
普通成员函数有this指针,可以访问类中任意成员;而静态成员函数没有this指针。
23、volatile和mutable
mutable是为了突破const的限制而设置的。被mutable修饰的变量将永远处于可变的状态,即使在一个const函数中,甚至结构体变量或者类对象为const,其mutable成员也可以被修改。mutable在类中只能修饰非静态数据成员。
一个定义为volatile的变量是说这变量是说这变量可能会被意想不到的修改,寄存器中的值没有发生变化,但是内存中的值有可能发生了变化,因此需要每次都从内存中取值。
24、说说volatile的应用
- 外围设备的特殊功能寄存器
- 在中断服务函数中修改全局变量
- 在多线程中修改全局变量
25、说说原子操作
原子操作(atomic operation)指的是由多步操作组成的一个操作。如果该操作不能原子地执行,则要么执行完所有步骤,要么一步也不执行,不可能只执行所有步骤的一个子集。
原子操作类似互斥锁,但是原子操作比锁效率更高,这是因为原子操作更加接近底层,它的实现原理是基于总线加锁和缓存加锁的方式。
在并发多线程的编程中,不同线程间对共享内存的竞争是存在一定危险的。所以C++11引入了自己的互斥量的概念来避免在多线程的运行中出现的问题,那么对于每次的加锁解锁以及其他的操作对于资源的消耗都是一定的,那么就又引入了std::atomic的类模板,实现了原子操作,从而避免了在数据的修改过程中被切换到另一个线程中,也就是说对于值的修改操作必须一次性执行完毕,中途不会被打断。atomic的运行效率上比互斥锁的效率要高好多。但是对于atomic和mutex的实际需要还需要根据设定情况来看,没有绝对的完美和高效。
std::atomic的用法简单,定义一个你所需要的变量就好,可以实现++,–,+=等操作,但是对于x = x + 1就不可用。
#include <iostream>
#include <atomic>
#include <thread>
std::atomic<int> myat;
void fun() {
for (int i = 0; i < 100000; i++) {
myat++;
}
}
int main()
{
std::thread t1(fun);
std::thread t2(fun);
t1.join();
t2.join();
std::cout << myat << std::endl;
return 0;
}
26、说说左值和右值
C++中有两种类型表达式:
- 左值:指向内存位置的表达式被称为左值表达式。左值可以出现在赋值号的左边或者右边。
- 右值:术语右值(rvalue)指的是存储在内存中某些地址的数值。右值是不能对其进行赋值的表达式,也就是说,右值可以出现在赋值号的右边,但不能出现在赋值号的左边。
变量是左值,因此可以出现在赋值号的左边。数值型的字面值是右值,因此不能被赋值,不能出现在赋值号的左边。
27、右值引用作用
C++11引入右值引用主要是为了实现移动语义和完美转发。
移动语义为了避免临时对象的拷贝,为类增加移动构造函数。
完美转发,就是通过一个函数将参数继续转交给另一个函数进行处理,原参数可能是右值,可能是左值,如果还能继续保持参数的原有特征,那么它就是完美的。
28、说说移动语义的原理
移动语义为了避免临时对象的拷贝,为类增加移动构造函数。移动构造函数与拷贝构造不同,它并不是重新分配一块新的空间同时将要拷贝的对象复制过来,而是"拿"了过来,将自己的指针指向别人的资源,然后将别人的指针修改为nullptr
29、多线程编程修改全局变量需要注意什么
多线程编程中,变量的值在内存中可能已经被修改,而编译器优化优先从寄存器里读值,读取的并不是最新值。
解决办法:
- 全局变量加关键字volatile
- 使用原子操作,效率比锁高
- 使用互斥锁
30、对象是值传递还是引用传递
- 引用传递对象
通常,使用对象作为参数的函数时,应按引用而不是按值来传递对象,这样可以有效的提高效率。 - 原因
因为按值传递的时候,将会涉及到调用拷贝构造函数生成临时的拷贝,然后又调用析构函数,这在大型的对象上要比传递引用花费的时间多的多。当我们不修改对象的时候,应当将参数声明为const引用。
31、拷贝构造函数的参数类型为什么必须是引用
使用值传递会调用拷贝构造函数,会陷入无穷递归之中。
32、初始化列表使用的场景
- 成员类型是没有默认构造函数类型的类。若没有提供显示初始化时,则编译器隐式使用成员类型的默认构造函数,若类没有默认构造函数,则编译器尝试使用默认构造函数将会失败。
- const成员或引用类型的成员。因为const对象或引用类型只能初始化,不能对他们赋值。
33、this指针
在每一个成员函数中都包含一个特殊的指针,这个指针的名字是固定的,称为this指针。它是指向本类对象的指针,它的值是当前被调用的成员函数所在的对象的起始地址。
对于一个类的实例来说,你可以看到它的成员函数、成员变量,但是实例本身呢?
this是一个指针,它时时刻刻指向你这个实例本身。
34、说说C++结构体和C结构体的区别?
- C不允许有函数存在,C++允许有函数,且可以是虚函数
- C内部成员权限为public,C++内部成员可以为public、protected、private。
- C不可继承,C++的结构体是可以从其他的结构体或者类继承过来的,默认公有继承
- C 中使用结构体需要加上 struct 关键字,或者对结构体使用 typedef 取别名,而 C++ 中可以省略 struct 关键字直接使用。
35、多态的理解
基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为多态(Polymorphism)。
可以通过引用实现多态
36、nullptr调用成员函数可以吗?为什么?
能,因为在编译时对象就绑定了函数地址,和指针空不空没关系。
37、请你说说虚函数的工作机制
C++实现虚函数的原理是虚函数表+虚表指针。
当一个类里存在虚函数时,编译器会为类创建一个虚函数表,虚函数表是一个数组,数组的元素存放的是类中虚函数的地址。
同时为每个类的对象添加一个隐藏成员,该隐藏成员保存了指向该虚函数表的指针。该隐藏成员占据该对象的内存布局的最前端。
所以虚函数表只有一份,而有多少个对象,就对应多少个虚函数表指针。
38、虚函数表在什么时候创建?每个对象都有一份虚函数表吗?
当一个类里存在虚函数时,编译器会为类创建一个虚函数表,发生在编译期。
虚函数表只有一份,而有多少个对象,就对应多少个虚函数表指针。
39、函数重载是怎么实现的?
在编译后,函数签名已经都不一样了,自然也就不冲突了。这就是为什么C++可以实现重名函数,但实际上编译后的函数签名是不一样的。
签名命名的方式是:_z+函数名字符个数+函数参数列表。
40、请你来介绍一下STL的allocaotr?
STL的分配器用于封装STL容器在内存管理上的底层细节。在C++中,其内存配置和释放如下:
- new运算分两个阶段:
- 调用::operator new配置内存;
- 调用对象构造函数构造对象内容
- delete运算分两个阶段;
- 调用对象析构函数;
- 调用对象::operator delete释放内存
为了精密分工,STL allocator将两个阶段操作区分开来:内存配置有alloc::allocate()负责,内存释放由alloc::deallocate()负责;对象构造由::construct()负责,对象析构由::destroy()负责。同时为了提升内存管理的效率,减少申请小内存造成的内存碎片问题,SGI STL采用了两级配置器,当分配的空间超过128字节时,会使用第一级空间配置器;当分配的空间小于128字节时,将使用第二级空间配置器。第一级空间配置器直接使用malloc()、realloc()、free()函数进行内存空间的分配和释放,而第二级空间配置器采用了内存池技术,通过空闲链表来管理内存。
41、请你来说一下map和set有什么区别,分别又是怎么实现的?
map和set都是C++的关联容器,其底层实现都是红黑树(RB-Tree)。由于 map 和set所开放的各种操作接口,RB-tree 也都提供了,所以几乎所有的 map 和set的操作行为,都只是转调 RB-tree 的操作行为。
map和set区别在于:
- map中的元素是key-value(关键字—值)对:关键字起到索引的作用,值则表示与索引相关联的数据;Set与之相对就是关键字的简单集合,set中每个元素只包含一个关键字。
- set的迭代器是const的,不允许修改元素的值;map允许修改value,但不允许修改key。其原因是因为map和set是根据关键字排序来保证其有序性的,如果允许修改key的话,那么首先需要删除该键,然后调节平衡,再插入修改后的键值,调节平衡,如此一来,严重破坏了map和set的结构,导致iterator失效,不知道应该指向改变前的位置,还是指向改变后的位置。所以STL中将set的迭代器设置成const,不允许修改迭代器的值;而map的迭代器则不允许修改key值,允许修改value值。
- map支持下标操作,set不支持下标操作。map可以用key做下标,map的下标运算符[ ]将关键码作为下标去执行查找,如果关键码不存在,则插入一个具有该关键码和mapped_type类型默认值的元素至map中,因此下标运算符[ ]在map应用中需要慎用,const_map不能用,只希望确定某一个关键值是否存在而不希望插入元素时也不应该使用,mapped_type类型没有默认值也不应该使用。如果find能解决需要,尽可能用find。
42、请你说一说C++ STL 的内存优化?
-
二级配置器结构 STL内存管理使用二级内存配置器。
- 第一级配置器 第一级配置器以malloc(),free(),realloc()等C函数执行实际的内存配置、释放、重新配置等操作,并且能在内存需求不被满足的时候,调用一个指定的函数。 一级空间配置器分配的是大于128字节的空间 如果分配不成功,调用句柄释放一部分内存 如果还不能分配成功,抛出异常
- 第二级配置器 在STL的第二级配置器中多了一些机制,避免太多小区块造成的内存碎片,小额区块带来的不仅是内存碎片,配置时还有额外的负担。区块越小,额外负担所占比例就越大。
- 分配原则 如果要分配的区块大于128bytes,则移交给第一级配置器处理。 如果要分配的区块小于128bytes,则以内存池管理(memory pool),又称之次层配置(sub-allocation):每次配置一大块内存,并维护对应的16个空闲链表(free-list)。下次若有相同大小的内存需求,则直接从free-list中取。如果有小额区块被释放,则由配置器回收到free-list中。 当用户申请的空间小于128字节时,将字节数扩展到8的倍数,然后在自由链表中查找对应大小的子链表 如果在自由链表查找不到或者块数不够,则向内存池进行申请,一般一次申请20块 如果内存池空间足够,则取出内存 如果不够分配20块,则分配最多的块数给自由链表,并且更新每次申请的块数 如果一块都无法提供,则把剩余的内存挂到自由链表,然后向系统heap申请空间,如果申请失败,则看看自由链表还有没有可用的块,如果也没有,则最后调用一级空间配置器
-
二级内存池采用了16个空闲链表,这里的16个空闲链表分别管理大小为8、16、24…120、128的数据块。这里空闲链表节点的设计十分巧妙,这里用了一个联合体既可以表示下一个空闲数据块(存在于空闲链表中)的地址,也可以表示已经被用户使用的数据块(不存在空闲链表中)的地址。
-
空间配置函数allocate 首先先要检查申请空间的大小,如果大于128字节就调用第一级配置器,小于128字节就检查对应的空闲链表,如果该空闲链表中有可用数据块,则直接拿来用(拿取空闲链表中的第一个可用数据块,然后把该空闲链表的地址设置为该数据块指向的下一个地址),如果没有可用数据块,则调用refill重新填充空间。
-
空间释放函数deallocate 首先先要检查释放数据块的大小,如果大于128字节就调用第一级配置器,小于128字节则根据数据块的大小来判断回收后的空间会被插入到哪个空闲链表。
-
重新填充空闲链表refill 在用allocate配置空间时,如果空闲链表中没有可用数据块,就会调用refill来重新填充空间,新的空间取自内存池。缺省取20个数据块,如果内存池空间不足,那么能取多少个节点就取多少个。 从内存池取空间给空闲链表用是chunk_alloc的工作,首先根据end_free-start_free来判断内存池中的剩余空间是否足以调出nobjs个大小为size的数据块出去,如果内存连一个数据块的空间都无法供应,需要用malloc取堆中申请内存。 假如山穷水尽,整个系统的堆空间都不够用了,malloc失败,那么chunk_alloc会从空闲链表中找是否有大的数据块,然后将该数据块的空间分给内存池(这个数据块会从链表中去除)。
-
总结:
- 使用allocate向内存池请求size大小的内存空间,如果需要请求的内存大小大于128bytes,直接使用malloc。
- 如果需要的内存小于128bytes,allocate根据size找到最适合的自由链表。
- 如果链表不为空,返回第一个node,链表头改为第二个node。
- 如果链表为空,使用blockAlloc请求分配node。
- 如果内存池中有大于一个node的空间,分配尽可能多的node(但是最多20个),将一个node返回,其他的node添加到链表中。
- 如果内存池只有一个node的空间,直接返回给用户。
- 若果如果连一个node都没有,再次向操作系统请求分配内存。 ①分配成功,再次进行b过程。 ②分配失败,循环各个自由链表,寻找空间。 I. 找到空间,再次进行过程b。 II. 找不到空间,抛出异常。
- 用户调用deallocate释放内存空间,如果要求释放的内存空间大于128bytes,直接调用free。
- 否则按照其大小找到合适的自由链表,并将其插入。
43、请你来回答一下include头文件的顺序以及双引号” ”和尖括号的区别?
Include头文件的顺序:对于include的头文件来说,如果在文件a.h中声明一个在文件b.h中定义的变量,而不引用b.h。那么要在a.c文件中引用b.h文件,并且要先引用b.h,后引用a.h,否则会报变量类型未声明错误。
双引号和尖括号的区别:编译器预处理阶段查找头文件的路径不一样。
对于使用双引号包含的头文件,查找头文件路径的顺序为:
- 当前头文件目录
- 编译器设置的头文件路径(编译器可使用-I显式指定搜索路径)
- 系统变量CPLUS_INCLUDE_PATH/C_INCLUDE_PATH指定的头文件路径
对于使用尖括号包含的头文件,查找头文件的路径顺序为:
- 编译器设置的头文件路径(编译器可使用-I显式指定搜索路径)
- 系统变量CPLUS_INCLUDE_PATH/C_INCLUDE_PATH指定的头文件路径
44、请你说一说vector、list和deque的区别,应用,越详细越好 ?
1. Vector
- 连续存储的容器,动态数组,在堆上分配空间
- 底层实现:数组
- 两倍容量增长:
vector 增加(插入)新元素时,如果未超过当时的容量,则还有剩余空间,那么直接添加到最后(插入指定位置),然后调整迭代器。
如果没有剩余空间了,则会重新配置原有元素个数的两倍空间,然后将原空间元素通过复制的方式初始化新空间,再向新空间增加元素,最后析构并释放原空间,之前的迭代器会失效。 - 性能:
访问:O(1)
插入:在最后插入(空间够):很快
在最后插入(空间不够):需要内存申请和释放,以及对之前数据进行拷贝。
在中间插入(空间够):内存拷贝
在中间插入(空间不够):需要内存申请和释放,以及对之前数据进行拷贝。
删除:在最后删除:很快
在中间删除:内存拷贝 - 适用场景:经常随机访问,且不经常对非尾节点进行插入删除。
1. push_back()和emplace_back()的区别
emplace_back() 和 push_back() 的区别,就在于底层实现的机制不同。push_back() 向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素);而 emplace_back() 在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。
2. insert()和emplace()的区别
当调用push或insert成员函数时,我们将元素类型的对象传递给他们,这些对象被拷贝到容器中。而当我们调用一个emplace成员函数时,则是将参数传递给元素类型的构造函数。emplace成员使用这些参数在容器管理的内存空间中直接构造元素。
2. List
- 动态链表,在堆上分配空间,每插入一个元数都会分配空间,每删除一个元素都会释放空间。
- 底层:双向链表
- 性能:
访问:随机访问性能很差,只能快速访问头尾节点。
插入:很快,一般是常数开销
删除:很快,一般是常数开销 - 适用场景:经常插入删除大量数据
3. deque
- deque(双端队列):是一个双开口的“连续空间”的数据结构
- 双开口:可以在首尾两端进行插入和删除操作
- 连续空间:deque并不是真正连续的,而是由一段段连续的小空间组合而成的
- deque类似于一个动态的二维数组,数据被依次存储在缓冲区中
- deque没有容量的概念,因为它是动态地以分段连续空间组合而成,随时可以增加一段新的空间并链接起来
- deque采用一块map作为主控,这里的map并非STL中的map容器,而是类似于动态一维数组的一小块连续的空间,其中每一个元素(node)都是一个指针,这个指针指向另一段较大的连续线性空间
- 中控区中指针指向的内存段称为缓冲区,缓冲区才是deque的存储空间主体
- deque迭代器具有的结构:
cur:迭代器表示的当前元素
first:缓冲区Buffer的起始位置
last:缓冲区Buffer的结束为止
node:保存此时位于map中的哪一个指针
4. 区别:
- vector底层实现是数组;list是双向链表。
- vector支持随机访问,list不支持。
- vector是顺序内存,list不是。
- vector在中间节点进行插入删除会导致内存拷贝,list不会。
- vector一次性分配好内存,不够时才进行2倍扩容;list每次插入新节点都会进行内存申请。
- vector随机访问性能好,插入删除性能差;list随机访问性能差,插入删除性能好。
5. 应用
vector拥有一段连续的内存空间,因此支持随机访问,如果需要高效的随即访问,而不在乎插入和删除的效率,使用vector。
list拥有一段不连续的内存空间,如果需要高效的插入和删除,而不关心随机访问,则应使用list。
45、请你来说一下STL中迭代器的作用,有指针为何还要迭代器?
-
迭代器
Iterator(迭代器)模式又称Cursor(游标)模式,用于提供一种方法顺序访问一个聚合对象中各个元素, 而又不需暴露该对象的内部表示。或者这样说可能更容易理解:Iterator模式是运用于聚合对象的一种模式,通过运用该模式,使得我们可以在不知道对象内部表示的情况下,按照一定顺序(由iterator提供的方法)访问聚合对象中的各个元素。
由于Iterator模式的以上特性:与聚合对象耦合,在一定程度上限制了它的广泛运用,一般仅用于底层聚合支持类,如STL的list、vector、stack等容器类及ostream_iterator等扩展iterator。 -
迭代器和指针的区别
迭代器不是指针,是类模板,表现的像指针。他只是模拟了指针的一些功能,通过重载了指针的一些操作符, ->、* 、++ 、–等。迭代器封装了指针,是一个“可遍历STL( Standard Template Library)容器内全部或部分元素” 的对象, 本质是封装了原生指针,是指针概念的一种提升(lift),提供了比指针更高级的行为,相当于一种智能指针,他可以根据不同类型的数据结构来实现不同的 ++,-- 等操作。
迭代器返回的是对象引用而不是对象的值,所以cout只能输出迭代器使用 * 取值后的值而不能直接输出其自身。 -
迭代器产生原因
Iterator类的访问方式就是把不同集合类的访问逻辑抽象出来,使得不用暴露集合内部的结构而达到循环遍历集合的效果。
46、请你来说一说STL迭代器删除元素?
这个主要考察的是迭代器失效的问题。
- 对于序列容器vector,deque来说,使用erase(itertor)后,后边的每个元素的迭代器都会失效,但是后边每个元素都会往前移动一个位置,但是erase会返回下一个有效的迭代器;
- 对于关联容器map set来说,使用了erase(iterator)后,当前元素的迭代器失效,但是其结构是红黑树,删除当前元素的,不会影响到下一个元素的迭代器,所以在调用erase之前,记录下一个元素的迭代器即可。
- 对于list来说,它使用了不连续分配的内存,并且它的erase方法也会返回下一个有效的iterator,因此上面两种正确的方法都可以使用。
47、请你说一说STL中map数据存放形式?
ans:
红黑树。unordered map底层结构是哈希表
C++中map的四种插入方式的比较及同值覆盖问题
方式 | 函数 | key值已存在时是否会覆盖原value值 |
---|---|---|
方法一 | pair | 不会覆盖 |
方法二 | make_pair | 不会覆盖 |
方法三 | value_type | 不会覆盖 |
方法四 | [ ] | 会覆盖 |
48、请你讲讲STL有什么基本组成?
STL主要由:以下几部分组成:
容器、迭代器、仿函数、算法、分配器、配接器
他们之间的关系:
- 分配器给容器分配存储空间
- 算法通过迭代器获取容器中的内容
- 仿函数可以协助算法完成各种操作
- 配接器用来套接适配仿函数
49、请你说说STL中map与unordered_map?
ans:
1、Map映射:map 的所有元素都是 pair,同时拥有实值(value)和键值(key)。pair 的第一元素被视为键值,第二元素被视为实值。所有元素都会根据元素的键值自动被排序。不允许键值重复。
底层实现:红黑树
适用场景:有序键值对不重复映射
2、Multimap多重映射:multimap 的所有元素都是 pair,同时拥有实值(value)和键值(key)。pair 的第一元素被视为键值,第二元素被视为实值。所有元素都会根据元素的键值自动被排序。允许键值重复。
底层实现:红黑树
适用场景:有序键值对可重复映射
map的底层是红黑树,unordered_map底层是哈希表,明明哈希表的查询效率更高,为什么还需要红黑树?
hashmap有unordered_map,map其实就是很明确的红黑树。map比起unordered_map的优势主要有:
- map始终保证遍历的时候是按key的大小顺序的,这是一个主要的功能上的差异。(有序无序)
- 时间复杂度上,红黑树的插入删除查找性能都是O(logN)而哈希表的插入删除查找性能理论上都是O(1),他是相对于稳定的,最差情况下都是高效的。哈希表的插入删除操作的理论上时间复杂度是常数时间的,这有个前提就是哈希表不发生数据碰撞。在发生碰撞的最坏的情况下,哈希表的插入和删除时间复杂度最坏能达到O(n)。
- map可以做范围查找,而unordered_map不可以。
- 扩容导致迭代器失效。 map的iterator除非指向元素被删除,否则永远不会失效。unordered_map的iterator在对unordered_map修改时有时会失效。
- 因为3,所以对map的遍历可以和修改map在一定程度上并行(一定程度上的不一致通常可以接受),而对unordered_map的遍历必须防止修改map的iterator可以双向遍历,这样可以很容易查找到当前map中刚好大于这个key的值,或者刚好小于这个key的值这些都是map特有而unordered_map不具备的功能。(这个不太明白,先放一放)
50、请你说一说epoll原理?
ans:
调用顺序:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
首先创建一个epoll对象,然后使用epoll_ctl对这个对象进行操作,把需要监控的描述添加进去,这些描述如将会以epoll_event结构体的形式组成一颗红黑树,接着阻塞在epoll_wait,进入大循环,当某个fd上有事件发生时,内核将会把其对应的结构体放入到一个链表中,返回有事件发生的链表。
51、n个整数的无序数组,找到每个元素后面比它大的第一个数,要求时间复杂度为O(N) ?
ans:
vector<int> FindMax(vector<int> &num)
{
int len=num.size();
if(len==0) return {}; //空数组,返回空
vector<int> res(len,-1); //返回结果:初始化-1,表示未找到
stack<int> notFind; //栈:num中还未找到符合条件的元素索引
int i=0;
while(i<len) //遍历数组
{
//如果栈空或者当前num元素不大于栈顶,将当前元素压栈, 索引 后移
if(notFind.empty() || num[notFind.top()]>=num[i])
{
notFind.push(i++);//处理索引
}
//有待处理元素,且num当前元素大于栈顶 索引 元素,符合条件,更新结果数组中该索引的值,栈顶出栈。
else
{
res[notFind.top()]=num[i];
notFind.pop();
}
}
return res;
}
52、请你回答一下STL里resize和reserve的区别?
ans:
- resize()
- resize(n)
调整容器的长度大小,使其能容纳n个元素。
如果n小于容器的当前的size,则删除多出来的元素。
否则,添加采用值初始化的元素。 - resize(n,t)
多一个参数t,将所有新添加的元素初始化为t。
而reserver()的用法只有一种reserve(n)预分配n个元素的存储空间。
- resize(n)
了解这两个函数的区别,首先要搞清楚容器的capacity(容量)与size(长度)的区别。
size:指容器当前拥有的元素个数;
capacity:则指容器在必须分配新存储空间之前可以存储的元素总数。
也可以说是预分配存储空间的大小。
resize()函数和容器的size息息相关。调用resize(n)后,容器的size即为n。
至于是否影响capacity,取决于调整后的容器的size是否大于capacity,大于size,capacity会成倍增长。
- reserve()函数和容器的capacity息息相关。
调用reserve(n)后,若容器的capacity<n,则重新分配内存空间,从而使得capacity等于n。
如果capacity>=n呢?capacity无变化。
从两个函数的用途可以发现,容器调用resize()函数后,所有的空间都已经初始化了,所以可以直接访问。
而reserve()函数预分配出的空间没有被初始化,所以不可访问
53、请你说一说stl里面set和map怎么实现的?
ans:
集合,所有元素都会根据元素的值自动被排序,且不允许重复。
底层实现:红黑树
set: 底层是通过红黑树(RB-tree)来实现的,由于红黑树是一种平衡二叉搜索树,自动排序的效果很不错,所以标准的 STL 的 set 即以 RB-Tree 为底层机制。又由于 set 所开放的各种操作接口,RB-tree 也都提供了,所以几乎所有的 set 操作行为,都只有转调用 RB-tree 的操作行为而已。
适用场景:有序不重复集合
map: 的所有元素都是 pair,同时拥有实值(value)和键值(key)。pair 的第一元素被视为键值,第二元素被视为实值。所有元素都会根据元素的键值自动被排序。不允许键值重复。
底层:红黑树
适用场景:有序键值对不重复映射
54、请你来说一下什么时候会发生段错误?
段错误通常发生在访问非法内存地址的时候,具体来说分为以下几种情况:
- 使用野指针
- 试图修改字符串常量的内容
55、如果构造函数加private会怎样?
- 如果将构造函数/析构函数声明为private,那只能这个类的“内部”的函数才能构造这个类的对象了。这里所说的“内部”不知道你是否能明白,下面举个例子吧。
class A
{
private:
A(){ }
~A(){ }
public:
void Instance()//类A的内部的一个函数
{
A a;
}
};
上面的代码是能通过编译的。上面代码里的Instance函数就是类A的内部的一个函数。Instance函数体里就构建了一个A的对象。
但是,这个Instance函数还是不能够被外面调用的。为什么呢?
如果要调用Instance函数,必须有一个对象被构造出来。但是构造函数被声明为private的了。外部不能直接构造一个对象出来。
A aObj; // 编译通不过
aObj.Instance();
但是,如果Instance是一个static静态函数的话,就可以不需要通过一个对象,而可以直接被调用。
#include <iostream>
using namespace std;
class A
{
private:
A():data(10){ cout << "A" << endl; }
~A(){ cout << "~A" << endl; }
public:
static A& Instance()
{
static A a;
return a;
}
void Print()
{
cout << data << endl;
}
private:
int data;
};
int main(int argc, char** argv)
{
A& ra = A::Instance();
ra.Print();
}
上面的代码其实是设计模式singleton模式的一个简单的C++代码实现。
还有一个情况是:通常将拷贝构造函数和operator=(赋值操作符重载)声明成private,但是没有实现体。这个的目的是禁止一个类的外部用户对这个类的对象进行复制动作。
create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
>首先创建一个epoll对象,然后使用epoll_ctl对这个对象进行操作,把需要监控的描述添加进去,这些描述如将会以epoll_event结构体的形式组成一颗红黑树,接着阻塞在epoll_wait,进入大循环,当某个fd上有事件发生时,内核将会把其对应的结构体放入到一个链表中,返回有事件发生的链表。
#### 73、n个整数的无序数组,找到每个元素后面比它大的第一个数,要求时间复杂度为O(N) ?
**ans:**
```c++
56、短路求值
#include <stdio.h>
int main()
{
int i = 6;
int j = 1;
if(i > 0 || (j++) > 0)
{
printf("%d\r\n",j);
}
return 0;
}
// 结果为 1
对于条件语句,||
前如果为true,则不必执行后面的语句,同理,如果&&
前面为false,也不必执行后面的语句,这就是短路求值。
57、排序算法比较
排序算法 | 平均复杂度 | 最坏时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|---|---|
冒泡排序 | O(n2) | O(n2) | O(1) | 是 |
选择排序 | O(n2) | O(n2) | O(1) | 不是 |
直接插入排序 | O(n2) | O(n2) | O(1) | 是 |
归并排序 | O(nlogn) | O(nlogn) | O(n) | 是 |
快速排序 | O(nlogn) | O(n2) | O(logn) | 是 |
堆排序 | O(nlogn) | O(nlogn) | O(1) | 不是 |
希尔排序 | O(nlogn) | O(n8) | O(1) | 不是 |
计数排序 | O(n+k) | O(n+k) | O(n+k) | 是 |
基数排序 | O(N*M) | O(N*M) | O(M) | 是 |
稳定性:快希选堆
58、C++STL中sort排序算法的底层实现方式和常见问题
STL的sort算法,数据量大时采用快速排序算法,分段归并排序。一旦分段后的数据量小于某个门槛(16),为避免QuickSort快排的递归调用带来过大的额外负荷,就改用插入排序。如果递归层次过深,还会改用堆排序。
适用:deque、vector、list
1.为什么对于区间小于16的采用快速排序,如果递归深度恶化改用堆排序?
- 插入排序对于基本有序或数据较少的序列很高效。
- 堆排序的时间复杂度固定为O(nlogn),不需要再递归下去了。
2.那堆排序既然也是O(nlogn)直接用堆排序实现sort不行吗?为啥用快速排序实现?
- 堆排序数据访问的方式没有快速排序友好。对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。 比如,堆排序中,最重要的一个操作就是数据的堆化。比如下面这个例子,对堆顶节点进行堆化,会依次访问数组下标是 1,2,4,8 的元素,而不是像快速排序那样,局部顺序访问,所以,这样对 CPU 缓存是不友好的。
- 对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。我们在讲排序的时候,提过两个概念,有序度和逆序度。对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(或移动)。快速排序数据交换的次数不会比逆序度多。
59、是否可以用memset来初始化一个类?
答:不可以!
这里说不可以,不是说真的不可以,而是说真的别这样!有些情况下是可以用的,因为类只是一个说明,对象也是这个类的一个具体化了的内存块,当你memset一个对象时,它把这块对象内存初始化了,在不影响内部结构的情况下是不会有问题的,这就是为什么有时候使用memset一个对象时不会出错的原因。
每个包含虚函数的类对象都有一个指针指向虚函数表(vtbl)。这个指针被用于解决运行时以及动态类型强制转换时虚函数的调用问题。该指针是被隐藏的,对程序员来说,这个指针也是不可存取的。当进行memset操作时,这个指针的值也要被overwrite,这样一来,只要一调用虚函 数,程序便崩溃。这在很多由C转向C++的程序员来说,很容易犯这个错误,而且这个错误很难查。
60、大端小端
大端模式:低字节在高地址上,高字节在低地址上。
小端模式:高字节在高地址上,低字节在低地址上。
如何判断计算机是大端还是小端
#include <stdio.h>
bool checkCPU()
{
{
union w
{
int a;
char b;
}c;
c.a = 1;
return (c.b == 1);
}
}
小端则1存在低地址上,取b时可以取出1;
大端则1存在高地址上,取b时只能取出0;
61、负数、浮点数的存储
1、负数
正数负数都是补码存放,正数补码为原码,负数(-1 ----> 1000 0001 高位表示符号)先反码,然后反码加一为补码 。
2、浮点数
62、memset
1. memset是以字节为单位,初始化内存块
当初始化一个字节单位的数组时,可以用memset把每个数组单元初始化成任何你想要的值,比如,
char data[10];
memset(data, 1, sizeof(data)); // right
memset(data, 0, sizeof(data)); // right
而在初始化其他基础类型时,则需要注意,比如,
int data[10];
memset(data, 0, sizeof(data)); // right
memset(data, -1, sizeof(data)); // right
memset(data, 1, sizeof(data)); // wrong, data[x] would be 0x0101 instead of 1
2. 当结构体类型中包含指针时
比如如下代码中,
struct Parameters {
int x;
int* p_x;
};
Parameters par;
par.p_x = new int[10];
memset(&par, 0, sizeof(par));
当memset初始化时,并不会初始化p_x指向的int数组单元的值,而会把已经分配过内存的p_x指针本身设置为0,造成内存泄漏。同理,对std::vector等数据类型,显而易见也是不应该使用memset来初始化的。
3. 当结构体或类的本身或其基类中存在虚函数时
这个问题就是在开头项目中发现的问题,如下代码中,
class BaseParameters
{
public:
virtual void reset() {}
};
class MyParameters : public BaseParameters
{
public:
int data[3];
int buf[3];
};
MyParameters my_pars;
memset(&my_pars, 0, sizeof(my_pars));
BaseParameters* pars = &my_pars;
//......
MyParameters* my = dynamic_cast<MyParameters*>(pars);
程序运行到dynamic_cast时发生异常。原因其实也很容易发现,我们的目的是为了初始化数据结构MyParameters里的data和buf,正常来说需要初始化的内存空间是sizeof(int) * 3 * 2 = 24字节,但是使用memset直接初始化MyParameters类型的数据结构时,sizeof(my_pars)却是28字节,因为为了实现多态机制,C++对有虚函数的对象会包含一个指向虚函数表(V-Table)的指针,当使用memset时,会把该虚函数表的指针也初始化为0,而dynamic_cast也使用RTTI技术,运行时会使用到V-Table,可此时由于与V-Table的链接已经被破坏,导致程序发生异常。
// error
strArry* GrientArr;
memset(GrientArr,0,sizeof(strArry));
//right
strArry* GrientArr=new strArry;
memset(GrientArr,0,sizeof(strArry));