c++作为面向对象设计最丰富的编程语言,具有类class、私有/共有/保护、引用、重载、继承、友元、虚函数、纯虚函数、运算符重载、模板等特性。运用这些特性来实现面向对象的封装、继承、多态。很多程序猿一头扎进去,有人用的炉火纯青,有人用的晕头转向,那么有没有想过,这些特性在编译器里是怎么实现的?是如何变成汇编在机器上执行的?如果由你来实现C++的编译器,你会如何实现这些特性?以下为个人理解,仅做记录,无需参考。
一、变量和函数
1、基本变量
基本变量是编程中常用的、基础的数据单位。
比如int、char、float等,他们在编译之后是一个固定长度的内存区域,比如int是32bit的,有符号位;char是8bit的,有符号位;unsigned char是8bit,没有符号位。符号即正负,用一位来表示,当没有符号位时,数据的值就多一位来表示。
指针是一种存储地址的变量,其长度由机器支持的内存地址长度决定。比如在32位的电脑上,指针的长度是32bit,在64位的电脑上,指针的长度就是64bit。
2、扩展变量
在基本变量基础上定义的变量,称为扩展变量。
比如数组,int a【10】,是在int的基础上定义的变量,在编译之后他由一个长度为10个int的区域表示。
比如结构体,
struct example{
char * a;
int b;
}
结构体example在编译后由一个指针和一个int型的变量区域组成。
3、函数
函数,包括入口地址、参数、返回值和函数代码段。c++成员函数和普通函数(非成员函数)的区别在于编译时对函数名和参数的处理,函数名会加上类名,参数会加上函数this指针。
4、内存对齐
c语言里面常常有内存对齐的问题,究其原因和cpu架构有关,有些cpu架构不支持非对齐内存访问,或把非对齐内存访问拆分成多次访问,降低了效率,所以编译时就先把数据对齐,不对齐的填充。
二、Class
类是c++面向对象的基础,class、public、private和protect一起完成面向对象的抽象和封装。结构体struct和class比较像,实际上class是由struct发展而来,在struct里面,所有的成员都是public的,而在class里面,成员默认是private的。class编译后也是由成员变量、成员函数语句组成,区别是在编译语句过程中会检查被调用的成员函数和成员变量的作用域,如果违反了private和protect的作用域规则,则编译报错。假设我们去掉这种编译报错,private、protect和public就没有区别了。
如前文所述,class其实也是一种扩展变量。
1、类的继承
子类能够继承父类的成员变量和成员函数,子类在编译后由父类+子类组成。至于三种继承方式public、private和protect,则是为了丰富继承后的效果。假设子类和父类的成员变量和成员函数全都是public的,那就和结构体包含子结构体一样了。
继承方式/基类成员 | public成员 | protected成员 | private成员 |
---|---|---|---|
public继承 | public | protected | 不可见 |
protected继承 | protected | protected | 不可见 |
private继承 | private | private | 不可见 |
实际上,父类的 private 成员是能够被继承的,并且(成员变量)会占用子类对象的内存,它只是在子类中不可见,导致无法使用罢了。这就和private继承protect有区别了,说白了,就是为了实现各种可能性,不能让private继承protect和private继承private变成一样的!
protect存在的意义就是为了丰富继承的多样性,单独使用父类时,protect和private是没有区别的。
2、友元
友元函数和友元类都能突破private和protect的限制,访问普通函数和类不能访问的数据区域,友元给我的第一感觉是破坏了封装性,友元的作用就相当于给封装留了一个后门,正门走不通可以爬狗洞。
3、继承过程中的磕磕盼盼
继承作为面向对象抽象的一大特性,在实现过程中会遇到很多问题。比如父类和子类都有同样的成员变量怎么办?有同样的成员函数怎么办?多重继承时重复的变量、函数和构造函数怎么办?子类怎么构造继承自父类的成员变量?
要理解这些,首先需要清楚全局变量、局部变量、结构体、函数、类在内存中的存储方式。
三、内存设计
C/C++程序把物理内存通常分为四个区:全局数据区(data area),代码区(code area),栈区(stack area),堆区(heap area)(即自由存储区)。全局数据区存放全局变量,静态数据和常量。所有类成员函数和非成员函数代码存放在代码区;为运行函数而分配的局部变量、函数参数、返回数据、返回地址等存放在栈区;余下的空间都被称为堆区,用于动态分配内存。
对于C++而言,
PS:类的实例如果是定义的类变量,则数据区存在栈内存区,如果是new出来的类指针,则在堆内存区,同时引用会保存在栈里。
这里比较特殊的就是类的数据区,我们知道类里面有静态变量、常量、非静态变量、成员函数、虚函数,这些部分并不是存在一块的,因为静态变量和成员函数是类的所有实例甚至子类共用的,如果每个类的实例都存一份,那就太不合理了,所以这些共用的部分一个类只存一份在代码区。虚函数由于多态的特性(第四节具体分析),函数体也存在代码区,但需要在类的数据区里面保持虚函数的指针,便于虚函数的调用。
回到继承的场景,普通的子类一般是对父类的扩展,如果涉及到成员函数的重载,我们知道重载函数的函数名一样,但参数不一样,所以在运行时是唯一的,因此也可以直接存储在代码段,不用特殊处理调用也不会出错。如果涉及到虚继承,则必须在数据区存储虚函数、虚基类的指针。
四、多态
通过基类指针调用基类和派生类中都有的同名、同参数表的虚函数的语句,编译时并不能确定要执行的是基类还是派生类的虚函数;而当程序运行到该语句时,如果基类指针指向的是一个基类对象,则基类的虚函数被调用,如果基类指针指向的是一个派生类对象,则派生类的虚函数被调用。这种机制就叫作“多态(polymorphism)”。如下面的例子可以很好的说明:
#include
using namespace std;
class A
{
public:
virtual void Print() { cout << "A::Print" << endl; }
};
class B : public A
{
public:
virtual void Print() { cout << "B::Print" << endl; }
};
void Printlnfo(A & r)
{
r.Print(); //多态,调用哪个Print,取决于r引用了哪个类的对象
}
int main()
{
A a; B b;
Printlnfo(a); //输出 A::Print
Printlnfo(b); //输出 B::Print
return 0;
}
程序的输出结果是:
A::Print
B::Print
我们看到,函数Printlnfo的参数是基类A的引用r,r既可以指向基类A,也可以指向派生类B,因此编译的时候,没办法确定r.Print()这个函数到底是类A的虚函数Print()还是类B的虚函数Print(),那怎么办?解决办法就是:
1、r是类的指针或引用,即地址;
2、在类的数据区存放虚函数的指针;
3、通过类的指针->虚函数指针的方式调用到正确的虚函数。
这就是为什么第三节讲的,虚函数的指针被存放到类的数据区,以实现多态。
五、引用
上一节说到引用和虚函数一起实现了多态。引用是变量的别名,是c++对c语言指针的改进版本。引用在底层还是一个指针,但编译器在编译引用时会做一些处理。我们看看引用的特性:
1. 引用只能在定义时初始化一次,之后不能改变指向其它变量(从一而终);指针变量的值可变。
2. 引用必须指向有效的变量,指针可以为空。
3. sizeof指针对象和引用对象的意义不一样。sizeof引用得到的是所指向的变量的大小,而sizeof指针是对象地址的大小。
4. 自增++、自减--、取地址& 对引用和指针的操作意义不一样。
其中1和2是为了改进指针的安全性,指针必须赋初始值,赋值完不能改变,并且是有效的初始值,一下子少了很多指针原因的安全性问题。3是为了简化指针的使用,更直接简单。4是编译器动手脚引起的后遗症。
所以c++鼓励大家用引用,少量场景引用替代不了指针,还是得用指针,但大部分情况用引用足够了。
看以下例子代码:
#include
using namespace std;
int main()
{
int x=1;
int &b=x;
return 0;
}
转汇编后的汇编代码:
int x = 1; //源代码
00401048 mov dword ptr [ebp-4],1 //反汇编代码
int &b = x; //源代码
0040104F lea eax,[ebp-4] //反汇编代码
00401052 mov dword ptr [ebp-8],eax//反汇编代码
在这里解释下这三行反汇编代码:
mov dword ptr [ebp-4],1 //把1赋值给ebp(栈底指针)-4的地址
lea eax,[ebp-4] //把ebp-4的地址赋值给寄存器eax
mov dword ptr [ebp-8],eax //把寄存器eax里的值赋值给ebp-8的这块地址
上述三行代码的作用就是将1赋值给x,然后将x的地址赋值给了引用b。在内存中,它是这样的:
注意:因为栈在内存中是由高地址向低地址增长的
通过底层的分析,可以看到引用是占内存空间的,存储的是所引用对象的地址。