回顾复习一些常见的C++问题,以及一些没有仔细考虑过的地方。(任何问题欢迎邮件:admin@franciswu.top)
虚函数表
虚函数表有一篇讲的非常好的文章:http://www.cnblogs.com/jerry19880126/p/3616999.html 面向对象的三大特性:封装、继承和多态。派生类对基类函数进行实现或者覆盖,通过基类的指针在调用的时候能调用到派生类的的函数,这个过程就是依靠虚函数表实现的。
- 1、new一个对象的时候,只会给类中的成员变量分配空间,对象之间共享成员函数
- 2、32位os 指针长度为4字节, 64位os 指针长度为8字节
sizeof是一个操作符,返回一个对象或类型所占的内存字节数,基本类型如short、int、long、float、double等的大小和操作系统有关。 结构体的sizeof涉及到字节对齐,规则如下:
- 1、结构体变量的首地址能够被其最宽基本类型成员的大小所整除
- 2、结构体的每个成员相对于结构体首地址的偏移量(offset)都是成员大小的整数倍,如有需要,编译器会在成员之间加上填充字节(internal adding)
- 3、结构体的总大小为结构体最宽基本类型成员大小的整数倍,如有需要,编译器会在最末一个成员后加上填充字节(trailing padding)。
- 4、空结构体(不含数据成员)的sizeof值为1
#include <iostream>
using namespace std;
class Base {
public:
virtual void f() {cout<<"base::f"<<endl;}
virtual void g() {cout<<"base::g"<<endl;}
virtual void h() {cout<<"base::h"<<endl;}
};
class Derive : public Base{
public:
void g() {cout<<"derive::g"<<endl;}
};
//可以稍后再看
int main () {
cout<<"size of Base: "<<sizeof(Base)<<endl;
typedef void(*Func)(void);
Base b;
Base *d = new Derive();
long* pvptr = (long*)d;
long* vptr = (long*)*pvptr;
Func f = (Func)vptr[0];
Func g = (Func)vptr[1];
Func h = (Func)vptr[2];
f();
g();
h();
return 0;
}
上面例子中取虚函数表中函数地址方式的原理如下图:
包含虚函数的类才会有虚函数表,同属于一个类的对象共享虚函数表,但是有各自的_vptr,虚函数表的实质是一个指针数组,里面存的是虚函数指针。
Base中的虚表结构:
Derive中的虚表结构:
内存管理
C++内存分为5个区域(堆栈全常代 ):
- 堆 heap : 堆向高地址方向增长。由new分配的内存块,其释放编译器不去管,由我们程序自己控制(一个new对应一个delete)。如果程序员没有释放掉,在程序结束时OS会自动回收。涉及的问题:“缓冲区溢出”、“内存泄露”
- 栈 stack : 栈向低地址方向增长是。那些编译器在需要时分配,在不需要时自动清除的存储区。存放局部变量、函数参数。 存放在栈中的数据只在当前函数及下一层函数中有效,一旦函数返回了,这些数据也就自动释放了。
- 全局/静态存储区 (.bss段和.data段) : 全局和静态变量被分配到同一块内存中。在C语言中,未初始化的放在.bss段中,初始化的放在.data段中;在C++里则不区分了。
- 常量存储区 (.rodata段) : 存放常量,不允许修改(通过非正当手段也可以修改)
- 代码区 (.text段) : 存放代码(如函数),不允许修改(类似常量存储区),但可以执行(不同于常量存储区),部分是编译后程序的主体,也就是程序的机器指令。
函数调用堆栈的过程
1、函数的运行都是在栈上开辟内存(比如函数的临时变量)。
2、栈是通过esp(栈顶指针)、ebp(栈底指针)两个指针来标识的。
3、对于栈上的访问都是通过栈底指针的偏移来访问的。
4、在call一个函数时,有两件事情要做:先将调用函数的下一行指令的地址压入栈中;再进行跳转。
5、在函数调用时检查函数是否申明、函数名是否相同、函数的参数列表是否匹配、函数的返回值多大。
6、函数结束ret指令干了两件事:先出栈;再将出栈的值放到CPU的PC寄存器中。因为PC寄存器中永远放的是下一次执行指令的地址,所以就顺理成章的在函数调用完之后依旧接着原来的代码继续执行。
在main函数调用func_A的时候,首先在自己的栈帧中压入函数返回地址,然后为func_A创建新栈帧并压入系统栈 在func_A调用func_B的时候,同样先在自己的栈帧中压入函数返回地址,然后为func_B创建新栈帧并压入系统栈 在func_B返回时,func_B的栈帧被弹出系统栈,func_A栈帧中的返回地址被“露”在栈顶,此时处理器按照这个返回地址重新跳到func_A代码区中执行 在func_A返回时,func_A的栈帧被弹出系统栈,main函数栈帧中的返回地址被“露”在栈顶,此时处理器按照这个返回地址跳到main函数代码区中执行
在实际运行中,main函数并不是第一个被调用的函数,程序被装入内存前还有一些其他操作,上图只是栈在函数调用过程中所起作用的示意图
析构函数
1、类的设计目的如果不是作为base class使用,或不是为了具备多态性,析构函数就不该声明为虚函数,原因如下:因为有虚函数的类就会有虚函数表存在,故对象体积会变大,同样因为虚表指针的存在,使得C++中类的对象不在和其他语言(如C)内的相同声明有着一样的结构,因此也就不再可能把它传递至或接受其它语言所写的函数,故不在有移植性(除非你名确补偿vptr)。 带多态性质的base class或类中带有任何virtual函数,就一定要拥有一个virtual析构函数,原因如下:假设derived class对象经由base class指针被删除,而该base class带着一个non-virtual析构函数,实际执行时通常会发生对象的derived成分没有被销毁。
2、不能在析构函数内调用虚函数,原因如下:一旦derived class析构函数开始执行,对象的derived class成员就会呈现未定义值,所以C++ 就会视它仿佛不存在,进入base class析构后,对象就成为一个base class对象,而virtual函数和 dynamic_casts也这么认为,这也就失去了它动态调用的意义了。
各类_cast
a、dynamic_cast
只能在有虚函数的情况下使用,依靠RTTI,要实现dynamic_cast,编译器会在每个含有虚函数的类的虚函数表的前四个字节存放一个指向_RTTICompleteObjectLocator结构的指针,当然还要额外空间存放RTTICompleteObjectLocator及其相关结构的数据。
b、static_cast
static_cast可以转换相关联的类,可以从子类转换成父类。也能从父类转向子类,但是如果转换的父类指针(或者父类引用)所指向的对象是完整的,那么是没有问题;但是如果所指向的对象并不完整,那么会出现runtime错误。
static_cast相对于dynamic_cast而言,除了能转换指针和引用,还能应用于任何能够隐式转换的情况。比如下面的情况:
void test_static_cast()
{
float f =1.012;
int i = static_cast<int>(f);
}
c、reinterpret_cast
该操作不会去进行动态类型或者静态类型的检测,它仅仅将值强行赋值过去。从某种意义上对编译器进行了一种欺骗,同时也带来了一定的不安全性。所以在使用这个cast的时候,要慎重。下面是这个操作的适用情况:
(1) Int和指针之间的相互转换;
(2) 无关联类指针之间的转换;
(3) 函数指针之间的转换
d、const_cast
用来去除const和volatile。如果原对象是const去除后也不能修改,如果原对象是非const则去除后可以修改。
C++标准库中中各种容器特征
1、顺序容器
-
array
array(数组)是一种最简单的标准库容器,定义于头文件
中,array的功能基本与普通的C数组,性能也是如此,只不过拥有了一些C++标准容器的特性例如查询大小、复制、迭代等。 -
vector与deque
vector(容器)中的内存是连续的,所以我们除了可以利用迭代器之外,还可以通过指针的偏移来访问其中的元素。vector定义于头文件
中,vector会在需要的时候动自动调整占用的内存的大小,与静态的array相比,vector占用的内存会更多,这一设计是为了降低重新分配内存的频率。我们可以通过capacity()来查询已分配的内存,通过shrink_to_fit()来归还已经分配的空闲的内存。我们可以随意向vector的任何位置添加、删除元素,但是只有在结尾删除元素的速度是最快的。同时如果对vector的大小有个预估,可以通过reserve()函数来为vector分配内存。 vector 上常见操作的复杂度(效率)如下:
-
随机访问 - 常数 O(1)
-
在尾部增删元素 - 平摊(amortized)常数 O(1)
-
增删元素 - 至 vector 尾部的线性距离 O(n)
与vector相反,deque(双向队列)其中的数据并不是连续储存的。deque允许在头部和尾部快速插入、删除元素。他定义在头文件
中:与vector类似,deque也会自动扩容。但是扩容deque的成本更低,因为deque不需要保证元素的连续性。同样,deque也是支持迭代器的。同时,向deque中插入元素不会使原有的指向元素的指针实效。 deque上常见操作的复杂性(效率)如下:
- 随机访问 - 常数O(1)
- 在结尾或开头插入或移除元素 - 摊销不变O(1)
- 插入或移除元素 - 线性O(n)
-
-
list与forward_list
list(双向链表)支持任意位置的快速插入和删除,但是作为代价,他并不支持随机访问。list通常实现为双向的链表,与list相比,forward_list(单向链表)只支持单向迭代,但其拥有更高的空间利用率。
2、关联容器
-
set与multiset
set是一个关联型容器,不支持随机访问。set(集合)中包含不重复、类型Key的元素。相反的,在multiset(多重集合)中的元素是可以重复的。 提示:无论是set还是multiset,都可以通过类型为Compare的比较函数来实现排序的。若遇到相等元素则他们的顺序就是插入时的顺序且不会改变。
-
map与multimap
map(映射)是一个有序关联容器,不支持随机访问。在map中,包含具有唯一键的键值对。键通过比较函数Compare来排序,通常被现为红黑树。如果需要一个键对应多个元素,就需要使用multimap(多重映射)。
提示:请尽量不要去遍历map和multimap,他们的设计决定了遍历的速度会很慢。
-
unordered_set,unordered_multiset和unordered_map
提示:以上四种无序容器通常都是用哈希表实现的,搜索,插入和去除具有平摊的常数时间复杂度,但是作为代价其内存占用会很高。