静态多态和动态多态优缺点
avl树与红黑树的效率
实际上红黑树的查找大约需要logN次比较, 并且查找不可能超过2*logN
次比较. 插入和删除的时间要增加一个常数因子, 因为不得不在下行的路径上和插入点执行颜色变化和旋转. 平均起来, 一次插入大约需要一次旋转. 因此插入的时间复杂度还是O(logN), 但是比在普通的二叉搜索树中要慢
avl树查找的时间复杂度为O(logN), 因为树一定是平衡的. 但是, 由于插入或删除一个节点时需要扫描两趟树, 一次向下查找插入点, 一次向上平衡树
AVL树保持每个结点的左子树与右子树的高度差至多为1, 从而可以证明树的高度为O(log(n)). Insert操作与delete操作的复杂度均为log(n), 旋转操作可能会达到log(n)次
avl树不如红黑树效率高, 也不如红黑树常用
有了avl树为什么还需要红黑树
1、红黑树放弃了追求完全平衡, 追求大致平衡, 在与平衡二叉树的时间复杂度相差不大的情况下, 保证每次插入最多只需要三次旋转就能达到平衡, 实现起来也更为简单.
2、平衡二叉树追求绝对平衡, 条件比较苛刻, 实现起来比较麻烦, 每次插入新节点之后需要旋转的次数不能预知.
avl树是为了解决二叉查找树退化为链表的情况, 而红黑树是为了解决平衡树在插入、删除等操作需要频繁调整的情况
总结
红黑树的查询性能略微逊色于AVL树,因为他比avl树会稍微不平衡最多一层,也就是说红黑树的查询性能只比相同内容的avl树最多多一次比较,但是,红黑树在插入和删除上完爆avl树,avl树每次插入删除会进行大量的平衡度计算,而红黑树为了维持红黑性质所做的红黑变换和旋转的开销,相较于avl树为了维持平衡的开销要小得多
所以简单说,如果你的应用中,搜索的次数远远大于插入和删除,那么选择AVL,如果搜索,插入删除次数几乎差不多,应该选择RB。
红黑树相比于BST和AVL树有什么优点
红黑树是牺牲了严格的高度平衡的优越条件为代价, 它只要求部分地达到平衡要求, 降低了对旋转的要求, 从而提高了性能. 红黑树能够以O(logn)的时间复杂度进行搜索、插入、删除操作. 此外, 由于它的设计, 任何不平衡都会在三次旋转之内解决. 当然, 还有一些更好的, 但实现起来更复杂的数据结构能够做到一步旋转之内达到平衡, 但红黑树能够给我们一个比较“便宜”的解决方案.
相比于BST, 因为红黑树可以能确保树的最长路径不大于两倍的最短路径的长度, 所以可以看出它的查找效果是有最低保证的. 在最坏的情况下也可以保证O(logN)的, 这是要好于二叉查找树的. 因为二叉查找树最坏情况可以让查找达到O(N).
红黑树的算法时间复杂度和AVL相同, 但统计性能比AVL树更高, 所以在插入和删除中所做的后期维护操作肯定会比红黑树要耗时好多, 但是他们的查找效率都是O(logN), 所以红黑树应用还是高于AVL树的. 实际上插入 AVL 树和红黑树的速度取决于你所插入的数据.如果你的数据分布较好,则比较宜于采用 AVL树(例如随机产生系列数),但是如果你想处理比较杂乱的情况,则红黑树是比较快的
构建一棵节点个数为 n 的红黑树, 时间复杂度
插入一个元素到红黑树的时间为log(N), 其中N为当前红黑树的元素个数, 因此, 采用插入方式构建元素个数为N的红黑树的时间复杂度为: log(1) + log(2) + log(N-1) = log((N-1)!) = Nlog(N)
红黑树相对于哈希表, 在选择使用的时候有什么依据
权衡三个因素: 查找速度, 数据量, 内存使用
总体来说, hash查找速度会比map快, 而且查找速度基本和数据量大小无关, 属于常数级别; 而map的查找速度是log(n)级别. 并不一定常数就比log(n)小, hash还有hash函数的耗时. 如果考虑效率, 特别是在元素达到一定数量级时, 考虑考虑hash. 但若你对内存使用特别严格, 希望程序尽可能少消耗内存, 那么一定要小心, hash可能会让你陷入尴尬, 特别是当你的hash对象特别多时, 你就更无法控制了, 而且hash的构造速度较慢.
红黑树并不适应所有应用树的领域. 如果数据基本上是静态的, 那么让他们待在他们能够插入, 并且不影响平衡的地方会具有更好的性能. 如果数据完全是静态的, 例如, 做一个哈希表, 性能可能会更好一些.
在实际的系统中, 例如, 需要使用动态规则的防火墙系统, 使用红黑树而不是散列表被实践证明具有更好的伸缩性. Linux内核在管理vm_area_struct时就是采用了红黑树来维护内存块的.
红黑树通过扩展节点域可以在不改变时间复杂度的情况下得到结点的秩.
如何扩展红黑树来获得比某个结点小的元素有多少个
这其实就是求节点元素的顺序统计量, 当然任意的顺序统计量都可以需要在O(lgn)时间内确定.
在每个节点添加一个size域, 表示以结点 x 为根的子树的结点树的大小, 则有
size[x] = size[[left[x]] + size [right[x]] + 1;
这时候红黑树就变成了一棵顺序统计树.
利用size域可以做两件事:
- 找到树中第i小的结点
OS-SELECT(x ,i) r = size[left[x]] + 1; if i == r return x elseif i < r return OS-SELECT(left[x], i) else return OS-SELECT(right[x], i)
思路: size[left[x]]表示在对x为根的子树进行中序遍历时排在x之前的个数, 递归调用的深度不会超过O(lgn);
- 确定某个结点之前有多少个结点, 也就是我们要解决的问题;
OS-RANK(T,x) r = x.left.size + 1; y = x; while y != T.root if y == y.p.right r = r + y.p.left.size +1 y = y.p return r
思路: x的秩可以视为在对树的中序遍历种, 排在x之前的结点个数加上一. 最坏情况下, OS-RANK运行时间与树高成正比, 所以为O (lgn).
例如红黑树有哪些应用场景
红黑树多用在内部排序, 即全放在内存中的, 微软STL的map和set的内部实现就是红黑树.
B树多用在内存里放不下, 大部分数据存储在外存上时. 因为B树层数少, 因此可以确保每次操作, 读取磁盘的次数尽可能的少.
在数据较小, 可以完全放到内存中时, 红黑树的时间复杂度比B树低. 反之, 数据量较大, 外存中占主要部分时, B树因其读磁盘次数少, 而具有更快的速度
class与struct区别
- 默认的继承权限
struct默认是公有继承(public),class默认是私有继承(private) - 关于默认访问权限
class中默认的成员访问权限是private的,而struct中则是public的。 - 关于大括号初始化问题
struct在C语言中: 在C语言中,我们知道struct中是一种数据类型,只能定义数据成员,不能定义函数,这是因为C语言是面向过程的,面向过程认为数据和操作是分开的,所以C语言中的struct可以直接使用大括号对所有数据成员进行初始化
struct test { int a; int b; }; //初始化 test A={1,2};//完全可以
在C++中class和struct的区别: 在C++中对struct的功能进行了扩展,struct可以被继承,可以包含成员函数,也可以实现多态,当用大括号对其进行初始化需要注意:
(1)当struct和class中都定义了构造函数,就不能使用大括号对其进行初始化
(2)若没有定义构造函数,struct可以使用{ }进行初始化,而只有当class的所有数据成员及函数为public时,可以使用{ }进行初始化
(3)所以struct更适合看成是一个数据结构的实现体,class更适合看成是一个对象的实现体。
- 在模板中,类型参数前面可以使用class或typename,如果使用struct,则含义不同,struct后面跟的是“non-type template parameter”,而class或typename后面跟的是类型参数。
空类大小
// vs2013 class X {}; // X: 1 class Y : public virtual X {}; // Y: 4 class Z : public virtual X {}; // Z: 4 class A : public Y, public Z {}; // A: 8
空类1字节时被编译器安插进入一个char, 使得这一class的两个objects得以在内存中配置独一无二的地址
class X {}; // X: 1 class Y : public X {}; // Y: 1, 和编译器有关 class Z : public X {}; // Z: 1, 和编译器有关 class A : public Y, public Z {}; // A: 1, 和编译器相关
class A {}; sizeof(A) = 1; class A { virtual void Fun(){} }; sizeof(A) = 4或者8, 32位指针4个字节, 64位指针8个字节 class A { static int a; }; sizeof(A) = 1; class A { int a; }; sizeof(A) = 4; class A { static int a; int b; }; sizeof(A) = 4;
红黑树定义
- 树的节点不是红色就是黑色
- 根节点永远为黑色
- 不能两个红色节点相邻
- 从根节点到所有非空子节点的高度(黑色节点数量)相同
函数调用压栈过程。32位压栈和64位压栈有什么区别
32位系统从左至右压栈
64位系统先把传入参数放在寄存器里面,在被调函数的具体实现中把寄存器的值入栈,然后再去栈中取参数
64位系统栈中参数存放的顺序是从左至右的(因为先经历了寄存器传值)
32位压栈和64位压栈区别
函数压栈过程
static关键字作用
全局静态变量
在全局变量前加上关键字static, 全局变量就定义成一个全局静态变量.
静态存储区, 在整个程序运行期间一直存在.
初始化: 未经初始化的全局静态变量会被自动初始化为0(自动对象的值是任意的, 除非他被显式初始化);
作用域: 全局静态变量在声明他的文件之外是不可见的, 准确地说是从定义之处开始, 到文件结尾.局部静态变量
在局部变量之前加上关键字static, 局部变量就成为一个局部静态变量.
内存中的位置: 静态存储区
初始化: 未经初始化的全局静态变量会被自动初始化为0(自动对象的值是任意的, 除非他被显式初始化) ;
作用域: 作用域仍为局部作用域, 当定义它的函数或者语句块结束的时候, 作用域结束. 但是当局部静态变量离开作用域后, 并没有销毁, 而是仍然驻留在内存当中, 只不过我们不能再对它进行访问, 直到该函数再次被调用, 并且值不变;静态函数
在函数返回类型前加static, 函数就定义为静态函数. 函数的定义和声明在默认情况下都是extern的, 但静态函数只是在声明他的文件当中可见, 不能被其他文件所用.
函数的实现使用static修饰, 那么这个函数只可在本cpp内使用, 不会同其他cpp中的同名函数引起冲突;
warning: 不要再头文件中声明static的全局函数, 不要在cpp内声明非static的全局函数, 如果你要在多个cpp中复用该函数, 就把它的声明提到头文件里去, 否则cpp内部声明需加上static修饰;类的静态成员
在类中, 静态成员可以实现多个对象之间的数据共享, 并且使用静态数据成员还不会破坏隐藏的原则, 即保证了安全性. 因此, 静态成员是类的所有对象中共享的成员, 而不是某个对象的成员. 对多个对象来说, 静态数据成员只存储一处, 供所有对象共用类的静态函数
静态成员函数和静态数据成员一样, 它们都属于类的静态成员, 它们都不是对象成员. 因此, 对静态成员的引用不需要用对象名.
在静态成员函数的实现中不能直接引用类中说明的非静态成员, 可以引用类中说明的静态成员(这点非常重要) . 如果静态成员函数中要引用非静态成员时, 可通过对象来引用. 从中可看出, 调用静态成员函数使用如下格式: ::();
面向对象三大特性?
封装性:数据和代码捆绑在一起,避免外界干扰和不确定性访问。
继承性:让某种类型对象获得另一个类型对象的属性和方法。
多态性:同一事物表现出不同事物的能力,即向不同对象发送同一消息,不同的对象在接收时会产生不同的行为(重载实现编译时多态,虚函数实现运行时多态)。
C++空类有哪些成员函数?
默认函数有:构造函数 析构函数 拷贝构造函数 赋值运算符
类模板是什么?
用于解决多个功能相同、数据类型不同的类需要重复定义的问题。
在建立类时候使用template及任意类型标识符T,之后在建立类对象时,会指定实际的类型,这样才会是一个实际的对象。
类模板是对一批仅数据成员类型不同的类的抽象,只要为这一批类创建一个类模板,即给出一套程序代码,就可以用来生成具体的类。
构造函数调用顺序,析构函数呢?
- 调用所有虚基类的构造函数,顺序为从左到右,从最深到最浅
- 基类的构造函数:如果有多个基类,先调用纵向上最上层基类构造函数,如果横向继承了多个类,调用顺序为派生表从左到右顺序。
- 如果该对象需要虚函数指针(vptr),则该指针会被设置从而指向对应的虚函数表(vtbl)。
- 成员类对象的构造函数:如果类的变量中包含其他类(类的组合),需要在调用本类构造函数前先调用成员类对象的构造函数,调用顺序遵照在类中被声明的顺序。
- 派生类的构造函数。
析构函数与之相反。
C++中四种cast转换
const_cast<type_id> (expression)
顾名思义, const_cast将转换掉表达式的const性质. 该运算符用来修改类型的const或volatile属性. 除了const 或volatile修饰之外, type_id和expression的类型是一样的
- 常量指针被转化成非常量的指针, 并且仍然指向原来的对象
- 常量引用被转换成非常量的引用, 并且仍然指向原来的对象
const int a=100; int * pa = const_cast<int *>(&a); *pa = 110; printf("%d,\n",a); printf("%d,\n",*pa); printf("0x%08x\n",&a); printf("0x%08x\n",pa); /* 100, 110, 0x006af9bc 0x006af9bc 请按任意键继续. . . 正确的结过是前面的一个是100, 一个是110. 是不是很奇怪, 好像跟前面说的不符啊. 我们再看看后面两个值, 竟然它们的地址是一样的, 这就更奇怪了。 其实, 前面的a是常量, 在预编译阶段, 常量会被真实数值替换, 就像define定义的宏一样. 于是, printf("%d,\n",a);其实也就相当于编译成printf("%d,\n",100); */
static_cast < type-id > ( expression )
static_cast与C语言式的强制转换实现的功能几近一样. 该运算符把expression转换为type-id类型, 但没有运行时类型检查来保证转换的安全性. 它主要有如下几种用法:
- 用于类层次结构中基类(父类)和派生类(子类)之间指针或引用的转换。
进行上行转换(把派生类的指针或引用转换成基类表示)是安全的;
进行下行转换(把基类指针或引用转换成派生类表示)时, 由于没有动态类型检查, 所以是不安全的。 - 用于基本数据类型之间的转换, 如把int转换成char, 把int转换成enum. 这种转换的安全性也要开发人员来保证。
- 把空指针转换成目标类型的空指针。
- 把任何类型的表达式转换成void类型。
const char a[] = "hahfasdfdf"; char *p = const_cast<char*>(a); // yes char *p1 = static_cast<char*>(a); // error char *p1 = reinterpret_cast<char*>(a); // error
dynamic_cast<type_id> (expression)
支持运行时识别指针或引用所指向的对象. 该运算符把expression转换成type-id类型的对象. type-id必须是类的指针、类的引用或者void*
. 如果type-id是类指针类型, 那么expression也必须是一个指针, 如果type-id是一个引用, 那么expression也必须是一个引用. dynamic_cast运算符可以在执行期决定真正的类型。
dynamic_cast主要用于类层次间的上行转换和下行转换, 还可以用于类之间的交叉转换
- 在类层次间进行上行转换时, dynamic_cast和static_cast的效果是一样的
- 在类层次间进行下行转换时, dynamic_cast具有类型检查的功能, 比static_cast更安全
class B{ public: int m_iNum; virtual void foo(); }; class D:public B{ public: char *m_szName[100]; }; void func(B *pb){ D *pd1 = static_cast<D *>(pb); D *pd2 = dynamic_cast<D *>(pb); } // 如果pb指向一个D类型的对象, pd1和pd2是一样的, 并且对这两个指针执行D类型的任何操作都是安全的; // 如果pb指向的是一个B类型的对象, 那么pd1将是一个指向该对象的指针, 对它进行D类型的操作将是不安全的(如访问m_szName),而pd2将是一个空指针。
reinterpret_cast<type_id> (expression)
该运算符把expression重新解释成type-id类型的对象. 对象在这里的范围包括变量以及实现类的对象. 此标识符的意思即为数据的二进制形式重新解释, 但是不改变其值。
与C语言式的强制转换有点类似?其实不然, C语言的会将一些数值之类的截断等处理, 比如浮点型转整形. 浮点型跟整形的保存数据处理方式是不同的, 但经过处理之后就变成了截断的数值. 而此时如果用reinterpret_cast来转换, 得到的数值肯定是让你诧异的值, 因为其实直接将那二进制的值重新当做另外一种数据类型来解释的。
- 为什么不使用C的强制转换?
C的强制转换表面上看起来功能强大什么都能转, 但是转化不够明确, 不能进行错误检查, 容易出错.
野指针是什么
野指针就是指向一个已删除的对象或者未申请访问受限内存区域的指针
C++中四个智能指针
C++里面的四个智能指针: auto_ptr, shared_ptr, weak_ptr, unique_ptr其中后三个是c++11支持, 并且第一个已经被11弃用. 头文件memory, linux编译需加-std=C++11
为什么要使用智能指针:
智能指针的作用是管理一个指针, 因为存在以下这种情况: 申请的空间在函数结束时忘记释放, 造成内存泄漏. 使用智能指针可以很大程度上的避免这个问题, 因为智能指针就是一个类, 当超出了类的作用域是, 类会自动调用析构函数, 析构函数会自动释放资源. 所以智能指针的作用原理就是在函数结束时自动释放内存空间, 不需要手动释放内存空间.
- auto_ptr(c++98的方案, cpp11已经抛弃)
采用所有权模式.
auto_ptr<string> p1(new string("I reigned lonely as a cloud.")); auto_ptr<string> p2; p2 = p1; // auto_ptr不会报错
此时不会报错, p2剥夺了p1的所有权, 但是当程序运行时访问p1将会报错. 所以auto_ptr的缺点是: 存在潜在的内存崩溃问题!
- unique_ptr(替换auto_ptr)
unique_ptr实现独占式拥有或严格拥有概念, 保证同一时间内只有一个智能指针可以指向该对象. 它对于避免资源泄露(例如“以new创建对象后因为发生异常而忘记调用delete”)特别有用.
采用所有权模式, 还是上面那个例子
unique_ptr<string> p3(new string("auto")); //#4 unique_ptr<string> p4; //#5 p4 = p3; //此时会报错!!
编译器认为p4=p3非法, 避免了p3不再指向有效数据的问题. 因此, unique_ptr比auto_ptr更安全.
另外unique_ptr还有更聪明的地方: 当程序试图将一个 unique_ptr 赋值给另一个时, 如果源 unique_ptr 是个临时右值, 编译器允许这么做;如果源 unique_ptr 将存在一段时间, 编译器将禁止这么做, 比如:
unique_ptr<string> pu1(new string ("hello world")); unique_ptr<string> pu2; pu2 = pu1; // #1 not allowed unique_ptr<string> pu3; pu3 = unique_ptr<string>(new string ("You")); // #2 allowed
其中#1留下悬挂的unique_ptr(pu1), 这可能导致危害. 而#2不会留下悬挂的unique_ptr, 因为它调用 unique_ptr 的构造函数, 该构造函数创建的临时对象在其所有权让给 pu3 后就会被销毁. 这种随情况而已的行为表明, unique_ptr 优于允许两种赋值的auto_ptr .
注: 如果确实想执行类似与#1的操作, 要安全的重用这种指针, 可给它赋新值. C++有一个标准库函数std::move(), 让你能够将一个unique_ptr赋给另一个. 例如:
unique_ptr<string> ps1, ps2; ps1 = demo("hello"); ps2 = move(ps1); ps1 = demo("alexia"); cout << *ps2 << *ps1 << endl; unique_ptr<string> p1(new string("hehe")); unique_ptr<string> p2; p2 = move(p1); cout << *p2 << endl; // 程序挂掉, 因为已经剥夺所有权了 cout << *p3 << endl;
- shared_ptr
shared_ptr实现共享式拥有概念. 多个智能指针可以指向相同对象, 该对象和其相关资源会在“最后一个引用被销毁”时候释放. 从名字share就可以看出了资源可以被多个指针共享, 它使用计数机制来表明资源被几个指针共享. 可以通过成员函数use_count()来查看资源的所有者个数. 除了可以通过new来构造, 还可以通过传入auto_ptr, unique_ptr,weak_ptr来构造. 当我们调用release()时, 当前指针会释放资源所有权, 计数减一. 当计数等于0时, 资源会被释放.
shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性(auto_ptr 是独占的), 在使用引用计数的机制上提供了可以共享所有权的智能指针.
成员函数:
use_count: 返回引用计数的个数
unique: 返回是否是独占所有权(use_count 为 1)
swap: 交换两个 shared_ptr 对象(即交换所拥有的对象)
reset: 放弃内部对象的所有权或拥有对象的变更, 会引起原有对象的引用计数的减少
get: 返回内部对象(指针), 由于已经重载了()方法, 因此和直接使用对象是一样的.如 shared_ptr
- weak_ptr
weak_ptr是一种不控制对象生命周期的智能指针, 它指向一个 shared_ptr管理的对象. weak_ptr设计的目的是为配合shared_ptr而引入的一种智能指针来协助shared_ptr工作, 它只可以从一个 shared_ptr或另一个weak_ptr对象构造, 它的构造和析构不会引起引用记数的增加或减少.
注意的是我们不能通过weak_ptr直接访问对象的方法, 比如B对象中有一个方法print(),我们不能这样访问, pa->pb_->print(); 英文pb_是一个weak_ptr, 应该先把它转化为shared_ptr,如: shared_ptr p = pa->pb_.lock(); p->print();
weak_ptr是用来解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放. 它是对对象的一种弱引用, 不会增加对象的引用计数, 和shared_ptr之间可以相互转化, shared_ptr可以直接赋值给它, 它可以通过调用lock函数来获得shared_ptr.
#include <iostream> #include <memory> class Child; class Parent { private: std::shared_ptr<Child> ChildPtr; public: void setChild(std::shared_ptr<Child> child) { this->ChildPtr = child; } void doSomething() { if (this->ChildPtr.use_count()) { } } ~Parent() { std::cout << "~Parent()" << std::endl; } }; class Child { private: std::shared_ptr<Parent> ParentPtr; //std::weak_ptr<Parent> ParentPtr; public: void setParent(std::shared_ptr<Parent> parent) { this->ParentPtr = parent; } void doSomething() { if (this->ParentPtr.use_count()) { } } ~Child() { std::cout << "~Child" << std::endl; } }; int main() { std::weak_ptr<Parent> wpp; std::weak_ptr<Child> wpc; { std::shared_ptr<Parent> p(new Parent); std::shared_ptr<Child> c(new Child); p->setChild(c); c->setParent(p); wpp = p; wpc = c; std::cout << p.use_count() << std::endl; std::cout << c.use_count() << std::endl; } std::cout << "----------" << std::endl; std::cout << wpp.use_count() << std::endl; std::cout << wpc.use_count() << std::endl; return 0; } /* 2 2 ---------- 1 1 请按任意键继续. . . */
上述代码中, parent有一个shared_ptr类型的成员指向孩子, 而child也有一个shared_ptr类型的成员指向父亲. 然后在创建孩子和父亲对象时也使用了智能指针c和p, 随后将c和p分别又赋值给child的智能指针成员parent和parent的智能指针成员child. 从而形成了一个循环引用
请你介绍一下C++中的智能指针
智能指针主要用于管理在堆上分配的内存, 它将普通的指针封装为一个栈对象. 当栈对象的生存周期结束后, 会在析构函数中释放掉申请的内存, 从而防止内存泄漏. C++ 11中最常用的智能指针类型为shared_ptr,它采用引用计数的方法, 记录当前内存资源被多少个智能指针引用. 该引用计数的内存在堆上分配. 当新增一个时引用计数加1, 当过期时引用计数减一. 只有引用计数为0时, 智能指针才会自动释放引用的内存资源. 对shared_ptr进行初始化时不能将一个普通指针直接赋值给智能指针, 因为一个是指针, 一个是类. 可以通过make_shared函数或者通过构造函数传入普通指针. 并可以通过get函数获得普通指针.
shared_ptr<string> p4 = make_shared<string>("zyb"); auto p5 = make_shared<string>("zyb");
shared_ptr注意事项
- 不要把一个原生指针给多个shared_ptr管理
- 不要把this指针给shared_ptr
- 不要在函数实参里创建shared_ptr
- 不要不加思考地把指针替换为shared_ptr来防止内存泄漏, shared_ptr并不是万能的, 而且使用它们的话也是需要一定的开销的
- 环状的链式结构shared_ptr将会导致内存泄漏(可以结合weak_ptr来解决)
- 共享拥有权的对象一般比限定作用域的对象生存更久, 从而将导致更高的平均资源使用时间
- 在多线程环境中使用共享指针的代价非常大, 这是因为你需要避免关于引用计数的数据竞争
- 共享对象的析构器不会在预期的时间执行
- 不使用相同的内置指针值初始化(或reset)多个智能指针
- 不delete get返回的指针
- 不使用get初始化或reset另一个智能指针
- 如果使用get返回的指针, 记住当最后一个对应的智能指针销毁后, 你的指针就变为无效了
- 如果你使用智能指针管理的资源不是new分配的内存, 记住传递给它一个删除器
智能指针的内存泄漏如何解决
为了解决循环引用导致的内存泄漏, 引入了weak_ptr弱指针, weak_ptr的构造函数不会修改引用计数的值, 从而不会对对象的内存进行管理, 其类似一个普通指针, 但不指向引用计数的共享内存, 但是其可以检测到所管理的对象是否已经被释放, 从而避免非法访问.
shared_ptr实现
主要就是构造函数
99_shared_ptr.cpp
请你理解的c++中的引用和指针
定义:
1、引用:
引用就是C++对C语言的重要扩充. 引用就是某一变量的一个别名, 对引用的操作与对变量直接操作完全一样. 引用的声明方法: 类型标识符 &引用名=目标变量名;引用引入了对象的一个同义词. 定义引用的表示方法与定义指针相似, 只是用&代替了*.
2、指针:
指针利用地址, 它的值直接指向存在电脑存储器中另一个地方的值. 由于通过地址能找到所需的变量单元, 可以说, 地址指向该变量单元. 因此, 将地址形象化的称为“指针”. 意思是通过它能找到以它为地址的内存单元.
区别: 代表意义、内存占用、初始化、指向是否可改、能否为空
- 指针有自己的一块空间, 而引用只是一个别名;
- 使用sizeof看一个指针的大小是4, 而引用则是被引用对象的大小;
- 指针可以被初始化为NULL, 而引用必须被初始化且必须是一个已有对象的引用;
- 指针在使用中可以指向其它对象, 但是引用只能是一个对象的引用, 不能被改变;
C/C++ 中指针和引用的区别
- 指针有自己的一块空间, 而引用只是一个别名
- 使用sizeof看一个指针的大小是4, 而引用则是被引用对象的大小
- 指针可以被初始化为NULL, 而引用必须被初始化且必须是一个已有对象的引用
- 作为参数传递时, 指针需要被解引用才可以对对象进行操作, 而直接对引用的修改都会改变引用所指向的对象
- 可以有const指针, 但是没有const引用
- 指针在使用中可以指向其它对象, 但是引用只能是一个对象的引用, 不能被改变
- 指针可以有多级指针(**p) , 而引用至于一级
- 指针和引用使用++运算符的意义不一样
- 如果返回动态内存分配的对象或者内存, 必须使用指针, 引用可能引起内存泄露.
请你说一说strcpy和strlen
strcpy是字符串拷贝函数, 原型: char *strcpy(char* dest, const char *src);
从src逐字节拷贝到dest, 直到遇到'\0'结束, 因为没有指定长度, 可能会导致拷贝越界, 造成缓冲区溢出漏洞,安全版本是strncpy函数.
strlen函数是计算字符串长度的函数, 返回从开始到'\0'之间的字符个数.
108_memcpy实现.cpp
110_strlen.cpp
请你来说一说++i和i++的实现
后置运算符返回临时变量
#include<iostream> using namespace std; class Test { friend Test& operator--(Test &obj); friend Test operator--(Test &obj, int); public: Test(int a = 0, int b = 0) { this->a = a; this->b = b; } void display() { cout << "a:" << a << " b:" << b << endl; } public: Test & operator++() { // 前置++ this->a++; this->b++; return *this; } Test operator++(int) { // 后置++ Test temp = *this; this->a++; this->b++; return temp; } private: int a; int b; }; Test& operator--(Test &obj) { // 前置-- obj.a--; obj.b--; return obj; } Test operator--(Test &obj, int) { //后置-- Test temp = obj; obj.a--; obj.b--; return temp; } int main() { Test t1(1, 2); t1.display(); ++t1; t1.display(); --t1; t1.display(); Test t2(3, 4); t2.display(); t2++; t2.display(); t2--; t2.display(); return 0; }
请你来说一说C++函数栈空间的最大值
默认是1M, 不过可以调整
请你来说一说extern“C”
C++调用C函数需要extern C, 因为C语言没有函数重载.
请你说说虚函数表具体是怎样实现运行时多态的?
子类若重写父类虚函数, 虚函数表中, 该函数的地址会被替换, 对于存在虚函数的类的对象, 在VS中, 对象的对象模型的头部存放指向虚函数表的指针, 通过该机制实现多态.
请你说说C语言是怎么进行函数调用的?
每一个函数调用都会分配函数栈, 在栈内进行函数执行过程. 调用前, 先把返回地址压栈, 然后把当前函数的esp指针压栈.
请你说说C语言参数压栈顺序?
从右到左
RVO
RVO:Return Value Optimization
这种特殊的优化——通过使用函数的return位置(或者在函数被调用位置用一个对象来替代)来消除局部临时对象——是众所周知的和被普遍实现的。它甚至还有一个名字:返回值优化。实际上,这种优化有自己的名字本身就可以解释为什么它被广泛地使用。
1)构造函数:func()函数中局部对象的构造 --> 省略
2)第一次构造函数:在函数的调用地方,将函数返回值 x 复制给临时对象temp --> 省略
3)外边若不是引用接这个临时对象会再调一次拷贝构造, 引用初始化接这个临时对象则临时对象转正
实际上边第一步已经省略, 直接构造一个返回对象
请你说说C++如何处理返回值
生成一个临时变量, 把它的引用作为函数参数传入函数内
请你回答一下C++中拷贝构造函数的形参能否进行值传递?
不能. 如果是这种情况下, 调用拷贝构造函数的时候, 首先要将实参传递给形参, 这个传递的时候又要调用拷贝构造函数. . 如此循环, 无法完成拷贝, 栈也会满.
请你回答一下C++类内可以定义引用数据成员吗
可以, 必须通过成员函数初始化列表初始化.
请你回答一下什么是右值引用, 跟左值又有什么区别
右值引用是C++11中引入的新特性 , 它实现了转移语义和精确传递. 它的主要目的有两个方面:
- 消除两个对象交互时不必要的对象拷贝, 节省运算存储资源, 提高效率.
- 能够更简洁明确地定义泛型函数.
左值和右值的概念:
左值: 能对表达式取地址、或具名对象/变量. 一般指表达式结束后依然存在的持久对象.
右值: 不能对表达式取地址, 或匿名对象. 一般指表达式结束就不再存在的临时对象.
右值引用和左值引用的区别:
- 左值可以寻址, 而右值不可以.
- 左值可以被赋值, 右值不可以被赋值, 可以用来给左值赋值.
- 左值可变, 右值不可变(仅对基础类型适用, 用户自定义类型右值引用可以通过成员函数改变) .
请你来说一下C++中类成员的访问权限
C++通过 public、protected、private 三个关键字来控制成员变量和成员函数的访问权限, 它们分别表示公有的、受保护的、私有的, 被称为成员访问限定符. 在类的内部(定义类的代码内部) , 无论成员被声明为 public、protected 还是 private, 都是可以互相访问的, 没有访问权限的限制. 在类的外部(定义类的代码之外) , 只能通过对象访问成员, 并且通过对象只能访问 public 属性的成员, 不能访问 private、protected 属性的成员
请你来回答一下include头文件的顺序以及双引号””和尖括号<>的区别?
Include头文件的顺序: 对于include的头文件来说, 如果在文件a.h中声明一个在文件b.h中定义的变量, 而不引用b.h. 那么要在a.c文件中引用b.h文件, 并且要先引用b.h, 后引用a.h,否则汇报变量类型未声明错误.
双引号和尖括号的区别: 编译器预处理阶段查找头文件的路径不一样.
- 对于使用双引号包含的头文件, 查找头文件路径的顺序为:
先搜索当前目录
然后搜索-I指定的目录
再搜索gcc的环境变量CPLUS_INCLUDE_PATH(C程序使用的是C_INCLUDE_PATH)
最后搜索gcc的内定目录 - 对于使用尖括号包含的头文件, 查找头文件的路径顺序为:
先搜索-I指定的目录
然后搜索gcc的环境变量CPLUS_INCLUDE_PATH(C程序使用的是C_INCLUDE_PATH)
最后搜索gcc的内定目录
请你来回答一下new和malloc的区别
操作符与库函数, 空间分配位置, 左边, 右边, 构造析构函数, 是否异常
- new分配内存按照数据类型进行分配, malloc分配内存按照指定的大小分配;
- new返回的是指定对象的指针, 而malloc返回的是
void*
, 因此malloc的返回值一般都需要进行类型转化. - new不仅分配一段内存, 而且会调用构造函数, malloc不会.
- new是一个操作符可以重载, malloc是一个库函数.
- new如果分配失败了会抛出bad_malloc的异常, 而malloc失败了会返回NULL.
- malloc分配的内存不够的时候, 可以用realloc扩容. 扩容的原理?new没用这样操作.
- new分配的内存要用delete销毁, malloc要用free来销毁;delete销毁的时候会调用对象的析构函数, 而free则不会.
- 申请数组时: new[]一次分配所有内存, 多次调用构造函数, 搭配使用delete[], delete[]多次调用析构函数, 销毁数组中的每个对象. 而malloc则只能
sizeof(int)*n
- new在自由存储区申请, malloc在堆上申请
new和delete是如何实现的, new 与 malloc的异同处
new、delete是操作符, 用来分配空间和清理对象的. new[]、delete[]是来为对象数组分配空间和清理对象的。
int* p1=new int; //分配一个int大小的空间 int* p2=new int(3); //分配一块空间, 并将空间初始化成3. int* p3=new int[3]; //分配3个int对象的空间。
- malloc/free只是动态分配内存空间/释放空间. 而new/delete除了分配空间还会调用构造函数和析构函数进行初始化与清理(清理成员)
- 它们都是动态管理内存的入口。
- malloc/free是C/C++标准库的函数, new/delete是C++操作符。
- malloc/free需要手动计算类型大小且返回值w为void*, new/delete可自动计算类型的大小, 返回对应类型的指针。
- malloc/free管理内存失败会返回0, new/delete等的方式管理内存失败会抛出异常。
operator new/operator delete、operator new[]/operator delete[]与malloc/free用法一样。
负责分配空间/释放空间, 但是不会调用构造函数和析构函数来初始化/清理对象。
实际上operator new/operator delete是malloc/free的一层封装。
new 做了两件事
- 调用operator new分配空间。
- 调用构造函数初始化对象。
delete做了两件事
- 调用析构函数清理对象。
- 调用operator delete释放空间。
new [N]
- 调用operator new分配空间。
- 调用N次构造函数来初始化对象。
delete[N]
- 调用N次析构函数清理对象。
- 调用operator delete释放空间。
这里会先取出头部的四个字节进而取得分配内存的大小
C和C++的区别与联系
联系:C++兼容了大部分C的语法。
内存管理方面的不同(new/delete、malloc/free的区别):
- new/delete是操作符, malloc/free是函数。(本身性质不同)
- new在使用时会调用malloc先开空间在调用构造函数进行初始化, delete会先调用析构函数清理对象, 然后在调用free释放空间。(申请内存底层不同是否调用构造函数和析构函数)
- malloc在堆上开空间, new是在自由存储区(堆或者静态存储区)开空间。(开辟空间的位置不同)
- malloc开空间的时候需要指定空间的大小, 而new只需要类型名(AA a1=new AA();)(开辟空间大小是否要指定)
- malloc开辟的空间可以给单个对象使用, 也可以给对象数组使用释放交给free; new 给对象数组开空间使用new[],对应释放使用delete[].(给对象和对象数组开辟空间的不同)
- malloc申请空间成功返回void*的指针, 失败返回NULL;new申请对象空间成功后返回对象指针, 失败会抛异常(成功返回值不同, 失败返回的也不同)
- malloc开辟的空间如果不够用可以使用realloc来扩大空间, 但是new不可以。(能否扩容)
C++有了面向对象的特性: 具有面向对象的特性, 继承封装多态
C的struct和C++的class: C中struct是结构体, C++对于strcut进行了扩展, 再C++中还可以当做类使用, 唯一不同的是默认访问修饰符不同. C语言中默认是public而C++中默认是private.
C语言不支持重载, C++支持重载
C++有了STL
C++中有inline、friend(友元)
堆与自由存储区
自由存储是C++中通过new和delete动态分配和释放对象的抽象概念,通过new来申请的内存区域可称为自由存储区。基本上,所有的C++编译器默认使用堆来实现自由存储,也即是缺省的全局运算符new和delete也许会按照malloc和free的方式来被实现,这时藉由new运算符分配的对象,说它在堆上也对,说它在自由存储区上也正确。但程序员也可以通过重载操作符,改用其他内存来实现自由存储,例如全局变量做的对象池,这时自由存储区就区别于堆了。
堆是操作系统维护的一块内存,而自由存储是C++中通过new与delete动态分配和释放对象的抽象概念。堆与自由存储区并不等价。
以下四行代码的区别是什么
const char *arr = "123";
字符串123保存在常量区, const本来是修饰arr指向的值不能通过arr去修改, 但是字符串“123”在常量区, 本来就不能改变, 所以加不加const效果都一样char *brr = "123";
字符串123保存在常量区, 这个arr指针指向的是同一个位置, 同样不能通过brr去修改"123"的值const char crr[] = "123";
这里123本来是在栈上的, 但是编译器可能会做某些优化, 将其放到常量区char drr[] = "123";
字符串123保存在栈区, 可以通过drr去修改
区别以下指针类型?
int *p[10] // int *p[10]表示指针数组,强调数组概念,是一个数组变量,数组大小为10,数组内每个元素都是指向int类型的指针变量。 int (*p)[10] // int (*p)[10]表示数组指针,强调是指针,只有一个变量,是指针类型,不过指向的是一个int类型的数组,这个数组大小是10。 int *p(int) // int *p(int)是函数声明,函数名是p,参数是int类型的,返回值是int *类型的。 int (*p)(int) // int (*p)(int)是函数指针,强调是指针,该指针指向的函数具有int类型参数,并且返回值是int类型的。
请你来回答一下const修饰成员函数的目的是什么?
const修饰的成员函数表明函数调用不会对对象做出任何更改, 事实上, 如果确认不会对对象做更改, 就应该为函数加上const限定, 这样无论const对象还是普通对象都可以调用该函数
const
- const修饰的量不是常量,仅仅是个只读量。在编译的时候全部替换const变量被赋予的值(这点和C语言的宏相似),在运行的时候该const变量可通过内存进行修改:
- 通过内存(指针)可以修改位于栈区的const变量,语法合乎规定,编译运行不会报错,但是在编译的时候所有用到该常量的地方全部被替换成了定义时所赋予的值,然后再运行的时候无法使用通过指针修改后的值。
- 通过内存(指针)修改位于静态存储区的的const变量,语法上没有报错,编译不会出错,一旦运行就会报告异常。
const只在编译期间保证常量被使用时的不变性,无法保证运行期间的行为。程序员直接修改常量会得到一个编译错误,但是使用间接指针修改内存,只要符合语法则不会得到任何错误和警告。因为编译器无法得知你是有意还是无意的修改,但是既然定义成const,那么程序员就不应当修改它,不然直接使用变量定义好了。
注: 通过指针修改在全局区上的const变量, 编译可通过,运行就会报异常
- const volatile修饰的变量,可以在编译时不用全部替换被const volatile变量被赋予的值,因此可以在运行时使用通过内存修改后的值:
- 通过内存(指针)可以修改位于栈区的const volatile变量,语法合乎规定,编译运行不会报错,在编译的时候所有用到该常量的地方不会替换成了定义时所赋予的值,在运行的时候可以使用通过指针修改后的值。
- 通过内存(指针)修改位于静态存储区的的const volatile变量,语法上没有报错,编译不会出错,一旦运行就会报告异常。
注: 通过指针修改在全局区上的const变量, 编译可通过,运行就会报异常
- const修饰的全局变量放在常量区, 因为常量区只读, 所以修改会发生错误
- const修饰的普通局部变量意味着在表达式上不能显式地改变该变量值,否则编译器会报语法错误,但该变量仍存放在栈区
如何修改const变量、const与volatile
注意const volatile
修饰全局变量同样不能使用指针进行改变
这就是C++中的常量折叠:指const变量(即常量)值放在编译器的符号表中,计算时编译器直接从表中取值,省去了访问内存的时间,从而达到了优化。
而在此基础上加上volatile修改符,即告诉编译器该变量属于易变的,不要对此句进行优化,每次计算时要去内存中取数。
define和const的区别(编译阶段、安全性、内存占用等)
- 编译器处理方式不同
define宏是在预处理阶段展开。
const常量是编译运行阶段使用。 - 类型和安全检查不同
define宏没有类型, 不做任何类型检查, 仅仅是展开。
const常量有具体的类型, 在编译阶段会执行类型检查。 - 存储方式不同
define宏仅仅是展开, 有多少地方使用, 就展开多少次, 不会分配内存。(宏定义不分配内存, 变量定义分配内存。)
const常量会在内存中分配(可以是堆中也可以是栈中)。 - const可以节省空间, 避免不必要的内存分配。 例如:
#define PI 3.14159 //常量宏 const double Pi=3.14159; //此时并未将Pi放入ROM中 ...... double i=Pi; //此时为Pi分配内存, 以后不再分配! double I=PI; //编译期间进行宏替换, 分配内存 double j=Pi; //没有内存分配 double J=PI; //再进行宏替换, 又一次分配内存!
const定义常量从汇编的角度来看, 只是给出了对应的内存地址, 而不是象#define一样给出的是立即数, 所以, const定义的常量在程序运行过程中只有一份拷贝(因为是全局的只读变量, 存在静态区),而 #define定义的常量在内存中有若干个拷贝。
- 提高了效率。
编译器通常不为普通const常量分配存储空间, 而是将它们保存在符号表中, 这使得它成为一个编译期间的常量, 没有了存储与读内存的操作, 使得它的效率也很高。 - 宏替换只作替换, 不做计算, 不做表达式求解;
宏预编译时就替换了, 程序运行时, 并不分配内存。
C++ 语言可以用const来定义常量, 也可以用#define来定义常量. 但是前者比后者有更多的优点:
- const常量有数据类型, 而宏常量没有数据类型. 编译器可以对前者进行类型安全检查. 而对后者只进行字符替换, 没有类型安全检查, 并且在字符替换可能会产生意料不到的错误(边际效应)。
- 有些集成化的调试工具可以对const常量进行调试, 但是不能对宏常量进行调试。
类中的常量
有时我们希望某些常量只在类中有效. 由于#define定义的宏常量是全局的, 不能达到目的, 于是想当然地觉得应该用const修饰数据成员来实现. const数据成员的确是存在的, 但其含义却不是我们所期望的。const数据成员只在某个对象生存期内是常量, 而对于整个类而言却是可变的, 因为类可以创建多个对象, 不同的对象其const数据成员的值可以不同。
不能在类声明中初始化const数据成员. 以下用法是错误的, 因为类的对象未被创建时, 编译器不知道SIZE的值是什么。
class A { // err const int SIZE = 100; // 错误, 企图在类声明中初始化const数据成员 int array[SIZE]; // 错误, 未知的SIZE }; class A{ // pass static const int SIZE = 100; int array[SIZE]; }; class A{ // err static int SIZE = 100; // 错误,企图在类声明中初始化const数据成员 int array[SIZE]; // 错误,未知的SIZE };
const数据成员的初始化只能在类构造函数的初始化表中进行
class A { A(int size) : SIZE(size) {} // 初始化列表中初始化const变量 const int SIZE; }; A a(100); // 对象 a 的SIZE值为100 A b(200); // 对象 b 的SIZE值为200
怎样才能建立在整个类中都恒定的常量呢?别指望const数据成员了, 应该用类中的枚举常量来实现。
- 枚举常量不会占用对象的存储空间, 它们在编译时被全部求值.
- 枚举常量的缺点是:它的隐含数据类型是整数, 其最大值有限, 且不能表示浮点数(如PI=3.14159)。sizeof(A) = 1200;其中枚举部长空间。
class A { enum { SIZE1 = 100, SIZE2 = 200 }; //枚举常量 int array1[SIZE1]; int array2[SIZE2]; }; int main() { enum EM { SIZE1 = 100, SIZE2 = 200 }; cout << sizeof(EM) << endl; cout << sizeof(A) << endl; return 0; } /* 4 1200 请按任意键继续. . . */
在C++中const和static的用法(定义, 用途)
const和static在类中使用的注意事项(定义、初始化和使用)
C++中的const类成员函数(用法和意义)
调用是否正确
class A { public: A(int a){ x = a; } //explicit A(int a){ x = a; } int x; }; int main() { A a = 1; // 构造函数使用explicit时错误 return 0; }
请你来说一说重载和覆盖
重载: 两个函数名相同, 但是参数列表不同(个数, 类型) , 返回值类型没有要求, 在同一作用域中
重写/覆盖: 子类继承了父类, 父类中的函数是虚函数, 在子类中重新定义了这个虚函数, 这种情况是重写
如果同时定义了两个函数, 一个带const, 一个不带, 会有问题吗?
不会, 这相当于函数的重载.
底层const重载
注:1)底层const即不能改变所指对象,所以传入常量和非常量都没有问题。
故如果传入非常量或者指向非常量的指针,重载的函数都能调用,但是对于编译器优先选择非const版本
传入常量或指向常量的指针时,只能选择const版本
2)如果没有重载非const版本,但是当传入一个非常量实参时希望返回一个非常量引用,则需对调用结果进行const_cast转换。const_cast能转换底层const,但是如果传入的实参是一个常量,转换后修改的话结果未定义。同时这种方式也会加重调用者的负担
请你来说一下函数指针
- 定义
函数指针是指向函数的指针变量.
函数指针本身首先是一个指针变量, 该指针变量指向一个具体的函数. 这正如用指针变量可指向整型变量、字符型、数组一样, 这里是指向函数.
C在编译时, 每一个函数都有一个入口地址, 该入口地址就是函数指针所指向的地址. 有了指向函数的指针变量后, 可用该指针变量调用函数, 就如同用指针变量可引用其他类型变量一样, 在这些概念上是大体一致的. - 用途:
调用函数和做函数的参数, 比如回调函数. - 示例:
char * fun(char * p) {…} // 函数fun char * (*pf)(char * p); // 函数指针pf pf = fun; // 函数指针pf指向函数fun pf(p); // 通过函数指针pf调用函数fun
C语言多态的实现
#include<stdio.h> typedef void(*fun)(); //这样可以直接用fun调用void(*)()的函数 struct base { fun _f; }; struct derived { base _b;//实现继承 }; void f1() { printf("%s\n", "base"); } void f2() { printf("%s\n", "derived"); } void main() { base b; //父类对象 derived d; //子类对象 b._f = f1; d._b._f = f2; //体现继承 base *p1 = &b; //父类指针指向父类对象 p1->_f(); base *p2 = (base*)&d; //将父类指针指向子类对象, 使用强制转换 p2->_f(); //指向的地址不变, 所以调用子类函数<父类指针指向子类对象, 实现多态> }
请你说说你了解的RTTI
运行时类型检查, 在C++层面主要体现在dynamic_cast和typeid,VS中虚函数表的-1位置存放了指向type_info的指针. 对于存在虚函数的类型, typeid和dynamic_cast都会去查询type_info
构造函数或者析构函数中调用虚函数会怎样
- 构造函数跟虚构函数里面都可以调用虚函数, 编译器不会报错。
- C++ primer中说到最好别用
- 由于类的构造次序是由基类到派生类, 所以在构造函数中调用虚函数, 虚函数是不会呈现出多态的
- 类的析构是从派生类到基类, 当调用继承层次中某一层次的类的析构函数时意味着其派生类部分已经析构掉, 所以也不会呈现多态
请你来说一下静态函数和虚函数的区别
静态函数在编译的时候就已经确定运行时机, 虚函数在运行的时候动态绑定. 虚函数因为用了虚函数表机制, 调用的时候会增加一次内存开销
请你说一说你理解的虚函数和多态
多态的实现主要分为静态多态和动态多态, 静态多态主要是重载, 在编译的时候就已经确定;动态多态是用虚函数机制实现的, 在运行期间动态绑定.
举个例子: 一个父类类型的指针指向一个子类对象时候, 使用父类的指针去调用子类中重写了的父类中的虚函数的时候, 会调用子类重写过后的函数, 在父类中声明为加了virtual关键字的函数, 在子类中重写时候不需要加virtual也是虚函数.
虚函数的实现: 在有虚函数的类中, 类的最开始部分是一个虚函数表的指针, 这个指针指向一个虚函数表, 表中放了虚函数的地址, 实际的虚函数在代码段(.text)中. 当子类继承了父类的时候也会继承其虚函数表, 当子类重写父类中虚函数时候, 会将其继承到的虚函数表中的地址替换为重新写的函数地址. 使用了虚函数, 会增加访问内存开销, 降低效率.
静态多态:重载、模板
动态多态:继承、虚函数、指向派生类的基类指针
静态绑定和动态绑定的介绍
为了支持c++的多态性, 才用了动态绑定和静态绑定. 理解他们的区别有助于更好的理解多态性, 以及在编程的过程中避免犯错误。
需要理解四个名词:
- 对象的静态类型:对象在声明时采用的类型. 是在编译期确定的。
- 对象的动态类型:目前所指对象的类型. 是在运行期决定的. 对象的动态类型可以更改, 但是静态类型无法更改。
关于对象的静态类型和动态类型, 看一个示例:
class B { }; class C : public B { } class D : public B { }; D* pD = new D(); // pD的静态类型是它声明的类型D*,动态类型也是D* B* pB = pD; // pB的静态类型是它声明的类型B*,动态类型是pB所指向的对象pD的类型D* C* pC = new C(); pB = pC; // pB的动态类型是可以更改的, 现在它的动态类型是C*
3、静态绑定:绑定的是对象的静态类型, 某特性(比如函数)依赖于对象的静态类型, 发生在编译期。
4、动态绑定:绑定的是对象的动态类型, 某特性(比如函数)依赖于对象的动态类型, 发生在运行期。
class B { void DoSomething(); virtual void vfun(); } class C : public B { //首先说明一下, 这个子类重新定义了父类的no-virtual函数, 这是一个不好的设计, 会导致名称遮掩;这里只是为了说明动态绑定和静态绑定才这样使用。 void DoSomething(); virtual void vfun(); } class D : public B { void DoSomething(); virtual void vfun(); } D* pD = new D(); B* pB = pD;
让我们看一下, pD->DoSomething()和pB->DoSomething()调用的是同一个函数吗?
不是的, 虽然pD和pB都指向同一个对象. 因为函数DoSomething是一个no-virtual函数, 它是静态绑定的, 也就是编译器会在编译期根据对象的静态类型来选择函数. pD的静态类型是D*,那么编译器在处理pD->DoSomething()的时候会将它指向D::DoSomething()。同理, pB的静态类型是B*,那pB->DoSomething()调用的就是B::DoSomething()。
让我们再来看一下, pD->vfun()和pB->vfun()调用的是同一个函数吗?
是的. 因为vfun是一个虚函数, 它动态绑定的, 也就是说它绑定的是对象的动态类型, pB和pD虽然静态类型不同, 但是他们同时指向一个对象, 他们的动态类型是相同的, 都是D*,所以, 他们的调用的是同一个函数:D::vfun()。
上面都是针对对象指针的情况, 对于引用(reference)的情况同样适用。
至于那些事动态绑定, 那些事静态绑定, 有篇文章总结的非常好: 只有虚函数才使用的是动态绑定, 其他的全部是静态绑定
特别需要注意的地方: 当缺省参数和虚函数一起出现的时候情况有点复杂, 极易出错. 我们知道, 虚函数是动态绑定的, 但是为了执行效率, 缺省参数是静态绑定的。
class B { virtual void vfun(int i = 10); } class D : public B { virtual void vfun(int i = 20); } D* pD = new D(); B* pB = pD; pD->vfun(); pB->vfun();
有上面的分析可知pD->vfun()和pB->vfun()调用都是函数D::vfun(),但是他们的缺省参数是多少?
分析一下, 缺省参数是静态绑定的, pD->vfun()时, pD的静态类型是D*
,所以它的缺省参数应该是20;同理, pB->vfun()的缺省参数应该是10. 编写代码验证了一下, 正确。
对于这个特性, 估计没有人会喜欢. 所以, 永远记住:“绝不重新定义继承而来的缺省参数(Never redefine function’s inherited default parameters value.)”
引用是否能实现动态绑定, 为什么引用可以实现
因为对象的类型是确定的, 在编译期就确定了
指针或引用是在运行期根据他们绑定的具体对象确定。
构造函数为什么一般不定义为虚函数
析构函数通过虚表来调用的, 而指向虚表的指针是在构造函数中初始化的, 这是个矛盾。
深拷贝和浅拷贝的区别
当我们把一个对象赋值给一个新的变量时, 赋的其实是该对象的在栈中的地址, 而不是堆中的数据. 也就是两个对象指向的是同一个存储空间, 无论哪个对象发生改变, 其实都是改变的存储空间的内容, 因此, 两个对象是联动的。
浅拷贝是按位拷贝对象, 它会创建一个新对象, 这个对象有着原始对象属性值的一份精确拷贝. 如果属性是基本类型, 拷贝的就是基本类型的值;如果属性是内存地址(引用类型),拷贝的就是内存地址 ,因此如果其中一个对象改变了这个地址, 就会影响到另一个对象. 即默认拷贝构造函数只是对对象进行浅拷贝复制(逐个成员依次拷贝),即只复制对象空间而不复制资源。
安全性:浅拷贝如果修改了指针指向的内容, 将对两个对象都有影响。
C++如何防止类被继承
- 构造函数声明为私有
- 虚继承一个构造函数声明为私有的基类,并且在基类中将派生类设为友元
防止类被拷贝
defaulte 和 delete
C++所有的构造函数
C++ 的类有四类特殊成员函数,它们分别是:默认构造函数、析构函数、拷贝构造函数以及拷贝赋值运算符。
请你回答一下为什么析构函数必须是虚函数?为什么C++默认的析构函数不是虚函数
将可能会被继承的父类的析构函数设置为虚函数, 可以保证当我们new一个子类, 然后使用基类指针指向该子类对象, 释放基类指针时可以释放掉子类的空间, 防止内存泄漏.
C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针, 占用额外的内存. 而对于不会被继承的类来说, 其析构函数如果是虚函数, 就会浪费内存. 因此C++默认的析构函数不是虚函数, 而是只有当需要当作父类时, 设置为虚函数.
拷贝构造函数为何第一个参数必须是引用
在值传递的方式传递给一个函数的时候,会调用拷贝构造函数生成函数的实参。如果拷贝构造函数的参数仍然是以值的方式,就会无限循环的调用下去,直到函数的栈溢出。
编译器强制性要求的
初始化列表
顺序、
效率: 内置类型不进行隐式初始化故无所谓,但类类型的数据成员对象在进入函数体前已经构造完成,也就是说在成员初始化列表处进行构造对象的工作调用构造函数,在进入函数体之后,进行的是对已经构造好的类对象的赋值,又调用个拷贝赋值操作符才能完成(如果并未提供,则使用编译器提供的默认按成员赋值行为)
const成员,引用成员必须通过初始值列表初始化
无默认构造函数的成员, 已经有其他构造函数, 但未提供无参构造函数, 这时可以通过拷贝构造函数的初始化列表来解决 -->
请你来说一下C++中析构函数的作用
析构函数与构造函数对应, 当对象结束其生命周期, 如对象所在的函数已调用完毕时, 系统会自动执行析构函数.
如果用户没有编写析构函数, 编译系统会自动生成一个缺省的析构函数(即使自定义了析构函数, 编译器也总是会为我们合成一个析构函数, 并且如果自定义了析构函数, 编译器在执行时会先调用自定义的析构函数再调用合成的析构函数) , 它也不进行任何操作. 所以许多简单的类中没有用显式的析构函数.
如果一个类中有指针, 且在使用的过程中动态的申请了内存, 那么最好显式构造析构函数在销毁类之前, 释放掉申请的内存空间, 避免内存泄漏.
类析构顺序: 1)派生类本身的析构函数;2)对象成员析构函数;3)基类析构函数.
什么情况下会调用拷贝构造函数
一个对象去初始化, 值传递类, 返回值是类
C++ 线程安全的单例模式
懒汉模式线程安全单例类
Singleton.h
Singleton_test.cpp
大端小端判断
大端模式: 数据的高字节保存在内存的低地址中, 而数据的低字节保存在内存的高地址中, 这样的存储模式有点儿类似于把数据当作字符串顺序处理:地址由小向大增加, 而数据从高位往低位放;这和我们的阅读习惯一致。
小端模式: 数据的高字节保存在内存的高地址中, 而数据的低字节保存在内存的低地址中, 这种存储模式将地址的高低和数据位权有效地结合起来, 高地址部分权值高, 低地址部分权值低。
/*return 1: little-endian, return 0: big-endian*/ int checkCPUendian() { union { unsigned int a; unsigned char b; }c; c.a = 1; return (c.b == 1); } // 联合体union的存放顺序是所有成员都从低地址开始存放, 利用该特性就可以轻松地获得了CPU对内存采用Little-endian还是Big-endian模式读写。
模板的用法与适用场景
代码可重用, 泛型编程, 在不知道参数类型下, 函数模板和类模板
成员初始化列表的概念, 为什么用成员初始化列表会快一些(性能优势)
使用初始化list这样可以直接调用成员的构造函数, 不用去赋值产生临时变量, 所以更快. 如果不用, 可能会去调用成员的构造函数和赋值构造函数(多出来的)。
volatile
程序在进行编译的时候, 编译器会进行一系列的优化.比如, 某个变量被修饰为 const的, 编译器会在寄存器中保存这个变量的值, 但是有时候, 我们去了这个变量的地址, 然后强行改变这个变量在内存中的值, 这就造成了结果的不匹配, 而volatile声明 的变量就会告诉编译器, 这个变量随时会改变, 需要每次都从内从中读取, 就是不需要优化, 从而避免了这个问题, 其实, volatile应用更多的场景是多线程对共享资源的访问的时候, 避免编译器的优化, 而造成多线程之间的通信不匹配!;
explicit:禁止类的构造函数进行隐式的类型转换
RAII
RAII全称是“Resource Acquisition is Initialization”,直译过来是“资源获取即初始化”,也就是说在构造函数中申请分配资源,在析构函数中释放资源
由对象来管理资源
C++程序执行main函数前和执行完main函数后会发生什么
首先main函数只不过是提供了一个函数入口, 在main函数中的显示代码执行之前, 会由编译器生成_main函数, 其中会进行所有全局对象的构造以及初始化工作. 简单来说对静态变量、全局变量和全局对象来说的分配是早在main()函数之前就完成的, 所以C/C++中并非所有的动作都是由于main()函数引起的
同理在main()函数执行后, 程序退出, 这时候会对全局变量和全局对象进行销毁操作, 所以在main()函数还会执行相应的代码
在下面的例子中, a的构造函数会先执行, 再执行main, 最后会调用a的析构函数
#include <iostream> class A { public: A() { printf("This is Constructor\n"); } ~A() { printf("This is destructor\n"); } }; A a; int main() { printf("This is main\n"); }
main函数开始之前有哪些工作
在调用main前先调用一个特殊的启动例程。可执行程序文件将次启动例程指定为程序的起始地址---由连接编辑器设置的,而连接编辑器由C编译器调用。
启动例程从内核读取命令行参数和环境变量值,然后为上述方式调用main函数做好安排
int main(int argc ,char **argc)参数的意思 #
argc是命令行总的参数个数
argv[]是argc个参数,其中第0个参数是程序的全名(绝对路径)。之后的参数是命令行后面跟的用户输入的参数
什么是多态
多态指同一个实体同时具有多种形式。它是面向对象程序设计(OOP)的一个重要特征
在面向对象语言中,接口的多种不同的实现方式即为多态
多态是具有表现多种形态的能力的特征,在OO中是指,语言具有根据对象的类型以不同方式处理之,特别是重载方法和继承类这种形式的能力。多态被认为是面向对象语言的必备特性
B树和B+树的总结
这都是由于B+树和B树具有不同的存储结构所造成的区别,以一个m阶树为例。
- 关键字的数量不同;B+树中分支结点有m个关键字,其叶子结点也有m个,其关键字只是起到了一个索引的作用,虽然B树也有m个子结点,但是其只拥有m-1个关键字。
- 存储的位置不同;B+树中的数据都存储在叶子结点上,也就是其所有叶子结点的数据组合起来就是完整的数据,但是B树的数据存储在每一个结点中,并不仅仅存储在叶子结点上。
- 分支结点的构造不同;B+树的分支结点存储着关键字信息和儿子的指针(这里的指针指的是磁盘块的偏移量),也就是说内部结点仅仅包含着索引信息。
- 查询不同;B树在找到具体的数值以后就结束,而B+树则需要通过索引找到叶子结点中的数据才结束,也就是说B+树的搜索过程中走了一条从根结点到叶子结点的路径,其高度是相同的,相对来说更加的稳定;
- 区间访问:B+树的叶子结点会按照顺序建立起链状指针,可以进行区间访问;
C++ 11新特性
nullptr 常量
constexpr 变量,声明为constexpr的变量一定是一个常量,而且必须用常量表达式来初始化
使用类型别名可以使复杂的类型名字变得更简单明了 (using)
auto
decltype
范围 for 语句
(13) C++的STL介绍(这个系列也很重要,建议侯捷老师的这方面的书籍与视频),其中包括内存管理allocator,函数,实现机理,多线程实现等
(14) STL源码中的hash表的实现
(15) STL中unordered_map和map的区别
(16) STL中vector的实现
(17) vector使用的注意点及其原因,频繁对vector调用push_back()对性能的影响和原因。
vector 中bool的表现形式
vector如何扩容
两个问题:
- 指数扩容而不是固定常数扩容
- 为什么是以两倍的方式扩容而不是三倍四倍,或者其他方式呢
容器和算法
vector/map等 erase要注意的
vector push_back要注意的(内存拷贝相关)
vector的优点、缺点。vector扩容如何避免数据的重新复制(类似deque?)
(13) C++的STL介绍(这个系列也很重要, 建议侯捷老师的这方面的书籍与视频),其中包括内存管理allocator, 函数, 实现机理, 多线程实现等
(14) STL源码中的hash表的实现
(15) STL中unordered_map和map的区别
(16) STL中vector的实现
(17) vector使用的注意点及其原因, 频繁对vector调用push_back()对性能的影响和原因。
请你讲讲STL有什么基本组成
STL主要由: 以下几部分组成:
容器, 迭代器, 仿函数, 算法, 分配器, 配接器
他们之间的关系: 分配器给容器分配存储空间, 算法通过迭代器获取容器中的内容, 仿函数可以协助算法完成各种操作, 配接器用来套接适配仿函数
请你来说一下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.
请你来说一说STL迭代器删除元素
这个主要考察的是迭代器失效的问题.
- 对于序列容器vector,deque来说, 使用erase(itertor)后, 后边的每个元素的迭代器都会失效, 但是后边每个元素都会往前移动一个位置, 但是erase会返回下一个有效的迭代器;
- 对于关联容器map set来说, 使用了erase(iterator)后, 当前元素的迭代器失效, 但是其结构是红黑树, 删除当前元素的, 不会影响到下一个元素的迭代器, 所以在调用erase之前, 记录下一个元素的迭代器即可.
- 对于list来说, 它使用了不连续分配的内存, 并且它的erase方法也会返回下一个有效的iterator, 因此上面两种正确的方法都可以使用.
请你说一说STL中MAP数据存放形式
红黑树. unordered_map底层结构是哈希表
请你说说STL中map与unordered_map
- map映射, map的所有元素都是pair, 同时拥有实值(value)和键值(key). pair的第一元素被视为键值, 第二元素被视为实值. 所有元素都会根据元素的键值自动被排序. 不允许键值重复.
底层实现: 红黑树
适用场景: 有序键值对不重复映射 - multimap多重映射. multimap 的所有元素都是 pair, 同时拥有实值(value) 和键值(key) . pair 的第一元素被视为键值, 第二元素被视为实值. 所有元素都会根据元素的键值自动被排序. 允许键值重复.
底层实现: 红黑树
适用场景: 有序键值对可重复映射
请你说一说vector和list的区别, 应用, 越详细越好
Vector
概念: 连续存储的容器, 动态数组, 在堆上分配空间
底层实现: 数组
两倍容量增长: vector增加(插入) 新元素时, 如果未超过当时的容量, 则还有剩余空间, 那么直接添加到最后(插入指定位置), 然后调整迭代器. 如果没有剩余空间了, 则会重新配置原有元素个数的两倍空间, 然后将原空间元素通过复制的方式初始化新空间, 再向新空间增加元素, 最后析构并释放原空间, 之前的迭代器会失效.
性能: 访问: O(1)
插入: 在最后插入(空间够) : 很快
在最后插入(空间不够) : 需要内存申请和释放, 以及对之前数据进行拷贝.
在中间插入(空间够) : 内存拷贝
在中间插入(空间不够) : 需要内存申请和释放, 以及对之前数据进行拷贝.
删除: 在最后删除: 很快
在中间删除: 内存拷贝
适用场景: 经常随机访问, 且不经常对非尾节点进行插入删除.List
概念: 动态链表, 在堆上分配空间, 每插入一个元数都会分配空间, 每删除一个元素都会释放空间.
底层实现: 双向链表
性能: 访问: 随机访问性能很差, 只能快速访问头尾节点.
插入: 很快, 一般是常数开销
删除: 很快, 一般是常数开销
适用场景: 经常插入删除大量数据区别:
1) vector底层实现是数组;list是双向链表.
2) vector支持随机访问, list不支持.
3) vector是顺序内存, list不是.
4) vector在中间节点进行插入删除会导致内存拷贝, list不会.
5) vector一次性分配好内存, 不够时才进行2倍扩容;list每次插入新节点都会进行内存申请.
6) vector随机访问性能好, 插入删除性能差;list随机访问性能差, 插入删除性能好.应用
vector拥有一段连续的内存空间, 因此支持随机访问, 如果需要高效的随即访问, 而不在乎插入和删除的效率, 使用vector.
list拥有一段不连续的内存空间, 如果需要高效的插入和删除, 而不关心随机访问, 则应使用list.
请你来说一下STL中迭代器的作用, 有指针为何还要迭代器
迭代器
Iterator(迭代器) 模式又称Cursor(游标) 模式, 用于提供一种方法顺序访问一个聚合对象中各个元素, 而又不需暴露该对象的内部表示. 或者这样说可能更容易理解: Iterator模式是运用于聚合对象的一种模式, 通过运用该模式, 使得我们可以在不知道对象内部表示的情况下, 按照一定顺序(由iterator提供的方法) 访问聚合对象中的各个元素.
由于Iterator模式的以上特性: 与聚合对象耦合, 在一定程度上限制了它的广泛运用, 一般仅用于底层聚合支持类, 如STL的list、vector、stack等容器类及ostream_iterator等扩展iterator.迭代器和指针的区别
迭代器不是指针, 是类模板, 表现的像指针. 他只是模拟了指针的一些功能, 通过重载了指针的一些操作符, ->、*、++、--等. 迭代器封装了指针, 是一个“可遍历STL( Standard Template Library) 容器内全部或部分元素”的对象, 本质是封装了原生指针, 是指针概念的一种提升(lift) , 提供了比指针更高级的行为, 相当于一种智能指针, 他可以根据不同类型的数据结构来实现不同的++, --等操作.
迭代器返回的是对象引用而不是对象的值, 所以cout只能输出迭代器使用*取值后的值而不能直接输出其自身.迭代器产生原因
Iterator类的访问方式就是把不同集合类的访问逻辑抽象出来, 使得不用暴露集合内部的结构而达到循环遍历集合的效果.
请你回答一下STL里resize和reserve的区别
resize(): 改变当前容器内含有元素的数量(size()),
eg: vector
reserve(): 改变当前容器的最大容量(capacity) ,它不会生成元素, 只是确定这个容器允许放入多少对象
- reserve(len)的值大于当前的capacity(), 那么会重新分配一块能存len个对象的空间, 然后把之前v.size()个对象通过copy construtor复制过来, 销毁之前的内存;
- reserve(len)的值小于当前元素含有的数量时失效
测试代码如下
#include <iostream> #include <vector> using namespace std; int main() { vector<int> a; a.reserve(100); a.resize(50); cout<<a.size()<<" "<<a.capacity()<<endl; //50 100 a.resize(150); cout<<a.size()<<" "<<a.capacity()<<endl; //150 150 a.reserve(50); cout<<a.size()<<" "<<a.capacity()<<endl; //150 150 a.resize(50); cout<<a.size()<<" "<<a.capacity()<<endl; //50 150 }
请你说一说stl里面set和map怎么实现的
set 底层是通过红黑树(RB-tree) 来实现的, 由于红黑树是一种平衡二叉搜索树, 自动排序的效果很不错, 所以标准的 STL 的 set 即以 RB-Tree 为底层机制. 又由于 set 所开放的各种操作接口, RB-tree 也都提供了, 所以几乎所有的 set 操作行为, 都只有转调用 RB-tree 的操作行为而已.
适用场景: 有序不重复集合
2、map
映射. map 的所有元素都是 pair, 同时拥有实值(value) 和键值(key) . pair 的第一元素被视为键值, 第二元素被视为实值. 所有元素都会根据元素的键值自动被排序. 不允许键值重复.
底层: 红黑树
适用场景: 有序键值对不重复映射
请你来介绍一下STL的allocator
STL的分配器用于封装STL容器在内存管理上的底层细节. 在C++中, 其内存配置和释放如下:
new运算分两个阶段:
(1)调用::operator new配置内存;
(2)调用对象构造函数构造对象内容
delete运算分两个阶段:
(1)调用对象希构函数
(2)调用::operator delete释放内存
为了精密分工, STL allocator将两个阶段操作区分开来:
内存配置有alloc::allocate()负责, 内存释放由alloc::deallocate()负责
对象构造由::construct()负责, 对象析构由::destroy()负责.
同时为了提升内存管理的效率, 减少申请小内存造成的内存碎片问题, SGI STL采用了两级配置器,
当分配的空间大小超过128B时, 会使用第一级空间配置器;
当分配的空间大小小于128B时, 将使用第二级空间配置器.
第一级空间配置器直接使用malloc()、realloc()、free()函数进行内存空间的分配和释放, 而第二级空间配置器采用了内存池技术, 通过空闲链表来管理内存.
请你说一说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的数据块. 这里空闲链表节点的设计十分巧妙, 这里用了一个联合体既可以表示下一个空闲数据块(存在于空闲链表中) 的地址, 也可以表示已经被用户使用的数据块(不存在空闲链表中) 的地址.
1、空间配置函数allocate
首先先要检查申请空间的大小, 如果大于128字节就调用第一级配置器, 小于128字节就检查对应的空闲链表, 如果该空闲链表中有可用数据块, 则直接拿来用(拿取空闲链表中的第一个可用数据块, 然后把该空闲链表的地址设置为该数据块指向的下一个地址) , 如果没有可用数据块, 则调用refill重新填充空间.
2、空间释放函数deallocate
首先先要检查释放数据块的大小, 如果大于128字节就调用第一级配置器, 小于128字节则根据数据块的大小来判断回收后的空间会被插入到哪个空闲链表.
3、重新填充空闲链表refill
在用allocate配置空间时, 如果空闲链表中没有可用数据块, 就会调用refill来重新填充空间, 新的空间取自内存池. 缺省取20个数据块, 如果内存池空间不足, 那么能取多少个节点就取多少个.
从内存池取空间给空闲链表用是chunk_alloc的工作, 首先根据end_free-start_free来判断内存池中的剩余空间是否足以调出nobjs个大小为size的数据块出去, 如果内存连一个数据块的空间都无法供应, 需要用malloc取堆中申请内存.
假如山穷水尽, 整个系统的堆空间都不够用了, malloc失败, 那么chunk_alloc会从空闲链表中找是否有大的数据块, 然后将该数据块的空间分给内存池(这个数据块会从链表中去除) . - 总结:
- 使用allocate向内存池请求size大小的内存空间, 如果需要请求的内存大小大于128bytes, 直接使用malloc.
- 如果需要的内存大小小于128bytes, allocate根据size找到最适合的自由链表.
a. 如果链表不为空, 返回第一个node, 链表头改为第二个node.
b. 如果链表为空, 使用blockAlloc请求分配node.
x. 如果内存池中有大于一个node的空间, 分配竟可能多的node(但是最多20个), 将一个node返回, 其他的node添加到链表中.
y. 如果内存池只有一个node的空间, 直接返回给用户.
z. 若果如果连一个node都没有, 再次向操作系统请求分配内存.
①分配成功, 再次进行b过程.
②分配失败, 循环各个自由链表, 寻找空间.
I. 找到空间, 再次进行过程b.
II. 找不到空间, 抛出异常.
- 用户调用deallocate释放内存空间, 如果要求释放的内存空间大于128bytes, 直接调用free.
- 否则按照其大小找到合适的自由链表, 并将其插入.
哈希如何解决冲突
开放定址法: 从发生冲突的那个单元起,按照一定的次序,从哈希表中找到一个空闲的单元。然后把发生冲突的元素存入到该单元的一种方法。开放定址法需要的表长度要大于等于所需要存放的元素。
(1)线行探查法: hi=(h(key)+i) mod M
(2)平方探查法: hi=(h(key)+i^2) mod M
(3)双散列函数探查法Hi=(h1(key)+i*k2(key)) mod M
开放定址法的缺点在于删除元素的时候不能真的删除,否则会引起查找错误,只能做一个特殊标记。只到有下个元素插入才能真正删除该元素。再哈希法: 就是同时构造多个不同的哈希函数. 若冲突换另一个hash函数再算一次
不易产生聚集,但增加了计算时间链地址法: 这种方法的基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法适用于经常进行插入和删除的情况
建立公共溢出区: 将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
拉链法与开放地址法相比的缺点:
拉链法的优点
与开放定址法相比,拉链法有如下几个优点:
①拉链法处理冲突简单,且无堆积现象,即非同义词决不会发生冲突,因此平均查找长度较短;
②由于拉链法中各链表上的结点空间是动态申请的,故它更适合于造表前无法确定表长的情况;
③开放定址法为减少冲突,要求装填因子α较小,故当结点规模较大时会浪费很多空间。而拉链法中可取α≥1,且结点较大时,拉链法中增加的指针域可忽略不计,因此节省空间;
④在用拉链法构造的散列表中,删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。而对开放地址法构造的散列表,删除结点不能简单地将被删结 点的空间置为空,否则将截断在它之后填人散列表的同义词结点的查找路径。这是因为各种开放地址法中,空地址单元(即开放地址)都是查找失败的条件。因此在 用开放地址法处理冲突的散列表上执行删除操作,只能在被删结点上做删除标记,而不能真正删除结点。
拉链法的缺点
拉链法的缺点是:指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间,而若将节省的指针空间用来扩大散列表的规模,可使装填因子变小,这又减少了开放定址法中的冲突,从而提高平均查找速度。