此文编写参考狄泰软件学院唐佐林老师的视频课程,如有错误之处,欢迎指正。
一、回归本质
c++对象模型其实本质上就是c++对象在内存中是如何排布的,具体的说就是c++中的对象的成员变量在内存中是如何排布的以及c++中的成员函数再内存中是如何排布的。
1、关于对成员变量的探索
class是一种特殊的struct
- 在内存中class依旧可以看成是变量的集合
- class与struct遵循相同的内存对齐规则,关于内存对齐规则可以参考博主的c进阶中的第一节
- class中的成员变量和成员函数是分开存放的
(1)每个对象都有独立的成员变量,这个变量可能存在于栈、堆或者全局数据区
(2)多有对象共享类中的成员函数,该成员函数只可能存在于代码段
下面通过一个实例来理解上述内容
#include<iostream>
#include<string>
using namespace std;
class A
{
private:
char a;
int b;
char c;
double d;
public:
void print()
{
cout<<"a="<<a<<endl
<<"b="<<b<<endl
<<"c="<<c<<endl
<<"d="<<d<<endl;
}
};
struct B
{
char a;
int b;
char c;
double d;
};
int main()
{
A a;
cout << "sizeof(A) = " << sizeof(A) << endl;
cout << "sizeof(a) = " << sizeof(a) << endl;
cout << "sizeof(B) = " << sizeof(B) << endl;
B* p=reinterpret_cast<B*>(&a);
p->a='a';
p->b=11;
p->c='c';
p->d=13.1;
a.print();
return 0;
}
总结:运行时的对象退化成结构体的形式
- 所有成员变量依次在内存中排布
- 成员变量可能存在内存空隙
- 可以通过内存地址直接访问内存变量
- 访问权限关键字在运行时失效
2、关于对成员函数的探索
在探索之前我们先提前准备一个知识点,就是关于void指针,具体介绍可参考这个链接
然后下面就开始进行探索了,首先先写一个简单的c++程序
例1
#include <iostream>
#include <string>
using namespace std;
class Demo
{
int mi;
int mj;
public:
Demo(int i, int j)
{
mi = i;
mj = j;
}
int getI()
{
return mi;
}
int getJ()
{
return mj;
}
int add(int value)
{
return mi + mj + value;
}
};
int main()
{
Demo d(1, 2);
cout << "sizeof(d) = " << sizeof(d) << endl;
cout << "d.getI() = " << d.getI() << endl;
cout << "d.getJ() = " << d.getJ() << endl;
cout << "d.add(3) = " << d.add(3) << endl;
return 0;
}
分析:上面讲到了类的成员函数是存在于代码段中的,类对象的大小并不取决与成员函数,而是取决于成员变量,那么它是如何区别出到底这个成员变量是属于哪一个对象呢,也就是调用成员函数的时候怎么知道访问的是当前对象的成员变量。其实在调用成员函数时会将对象地址当做参数隐式传递,然后成员函数再通过对象地址访问成员成员变量,c++语法规则隐藏了对象地址的传递过程,下面我们用c语言来写面向对象的程序,以探索其深层次的内容。
例2
test.h
#ifndef _TEST_H_
#define _TEST_H_
typedef void Demo;
Demo* Demo_create(int i,int j);
int Demo_GETI(Demo* pThis);
int Demo_GETJ(Demo* pThis);
int Demo_Add(Demo* pThis,int value);
void Demo_free(Demo* pThis);
#endif
test.c
#include"test.h"
#include<malloc.h>
struct ClassDemo
{
int mi;
int mj;
};
Demo* Demo_create(int i,int j)
{
struct ClassDemo* ret=(struct ClassDemo*)malloc(sizeof(struct ClassDemo));
if(ret!=NULL)
{
ret->mi=i;
ret->mj=j;
}
return ret;
}
int Demo_GETI(Demo* pThis)
{
struct ClassDemo* obj=(struct ClassDemo*)pThis;
return obj->mi;
}
int Demo_GETJ(Demo* pThis)
{
struct ClassDemo* obj=(struct ClassDemo*)pThis;
return obj->mj;
}
int Demo_Add(Demo* pThis,int value)
{
struct ClassDemo* obj=(struct ClassDemo*)pThis;
return obj->mi+obj->mj+value;
}
void Demo_free(Demo* pThis)
{
free(pThis);
}
main.c
#include<stdio.h>
#include"test.h"
int main()
{
Demo* d=Demo_create(1, 2);
printf("d.mi=%d\n",Demo_GETI(d));
printf("d.mj=%d\n",Demo_GETJ(d));
printf("Add(3)=%d\n",Demo_Add(d, 3));
Demo_free(d);
return 0;
}
总结:
- c++类对象在内存布局上与结构体相同
- 成员变量和成员函数在内存中分开存放
- 访问权限关键字在运行时失效
- 调用成员函数时对象地址作为参数隐式传递
二、 继承对象模型
在c++编译器内部类可以理解为结构体,继承的对象模型又是怎样的呢,结论是:子类是由父类成员叠加子类新成员得到的,我们可以通过结构体来验证。
#include<iostream>
#include<string>
using namespace std;
class Parent
{
protected:
int a;
int b;
};
class Child:public Parent
{
private:
int c;
public:
Child(int a,int b,int c)
{
this->a=a;
this->b=b;
this->c=c;
}
void print()
{
cout<<"a="<<a<<endl
<<"b="<<b<<endl
<<"c="<<c<<endl;
}
};
struct Test
{
int e;
int b;
int c;
};
int main()
{
cout<<"sizeof(Parent)="<<sizeof(Parent)<<endl;//8
cout<<"sizeof(Child)="<<sizeof(Child)<<endl;//12
cout<<"before changing....."<<endl;
Child c(1,2,3);
c.print();
cout<<"after changing....."<<endl;
Test* p=reinterpret_cast<Test*>(&c);
p->a=10;
p->b=20;
p->c=30;
c.print();
return 0;
}
分析:实验结果验证了上述的结论。
三、多态的对象模型
1、多态的实现原理
(1)、多态:相同的行为方式却有不同的行为结果
(2)、实现方式:在c++中通过虚函数来实现
(3)、具体实现原理:
- 当类中声明虚函数时,编译器会在类中生成一个虚函数表
- 什么是虚函数表?虚函数表是一个存储成员函数地址的数据结构,是由编译器自动生成和维护的
- 当用virtual关键字声明成员函数时,该成员函数的地址就会被编译器放入虚函数表中,同时在该对象的成员变量中强行插入一个指针,该指针指向虚函数表,且该指针对外是不可见的。
如上图所示:父类和子类都同时将add函数声明为虚函数,所以这时编译器会分别在这两个类中生成一个虚函数表,这个虚函数表存放这add函数的地址,同时在这两个类中的成员变量里会强行插入一个指针,该指针指向他们的虚函数表。再如我们定义了一个run的函数,如下图所示。
这个实现步骤是这样的:首先p先指向当前对象,然后确定要调用的函数是不是虚函数,如果是虚函数,那么通过该对象中指向虚函数的指针找到虚函数表,找到后,查找出add函数的地址,最后调用该函数,如果不是虚函数,编译器就可以直接确定被调成员函数的地址了。所以这两种调用效率相比,显然虚函数要比普通成员函数的效率要低,所以在考虑性能的时候要注意虚函数的合理使用了。
下面我们用例程验证一下这个被编译器强行插入到类的指针成员变量的存在性。
#include<iostream>
#include<string>
using namespace std;
class Parent
{
protected:
int a;
int b;
public:
virtual void print()//此处声明为虚函数,该函数地址会被编译器放到虚函数表中,同时编译器会强行插入一个指向该虚函数表的成员变量
{
cout<<"a="<<a<<endl
<<"b="<<b<<endl;
}
};
class Child:public Parent
{
private:
int c;
public:
Child(int a,int b,int c)
{
this->a=a;
this->b=b;
this->c=c;
}
void print()
{
cout<<"a="<<a<<endl
<<"b="<<b<<endl
<<"c="<<c<<endl;
}
};
struct Test
{
void *p;//定义一个指针,以证明内存模型和上面的父类子类成员叠加后的内存模型是一样的,从而证明确实存在一个指向虚函数数表的指针
int a;
int b;
int c;
};
int main()
{
cout<<"sizeof(Parent)="<<sizeof(Parent)<<endl;
cout<<"sizeof(Child)="<<sizeof(Child)<<endl;
cout<<"before changing....."<<endl;
Child c(1,2,3);
c.print();
cout<<"after changing....."<<endl;
Test* p=reinterpret_cast<Test*>(&c);
//下面这段代码能够正常运行
p->a=10;
p->b=20;
p->c=30;
c.print();
return 0;
}
分析:因为在父类中插入了一个指向虚函数表的指针,指针类型所占空间为8个字节,因此sizeof(Parent)=16。
2、用c语言实现多态
步骤:
- 用c写父类的面向对象程序
- 用c写子类的面向对象程序
- 分别写好父类和子类的虚函数
- 创建虚函数表,用结构体的形式
- 在虚函数表中创建保存虚函数地址的成员变量
- 声明两个虚函数表的结构体变量,分别存放虚函数的地址
- 在父类中插入指向虚函数表的指针变量
- 在对象创建函数中将对象和虚函数表进行关联
51-2.h
#ifndef _51_2_H_
#define _51_2_H_
typedef void Demo;
typedef void Derived;
Demo* Demo_Create(int i, int j);
int Demo_GetI(Demo* pThis);
int Demo_GetJ(Demo* pThis);
int Demo_Add(Demo* pThis, int value);
void Demo_Free(Demo* pThis);
Derived* Derived_Create(int i, int j, int k);
int Derived_GetK(Derived* pThis);
int Derived_Add(Derived* pThis, int value);
#endif
51-2.c
#include "51-2.h"
#include "malloc.h"
static int Demo_Virtual_Add(Demo* pThis, int value);
static int Derived_Virtual_Add(Demo* pThis, int value);
struct VTable // 2. 定义虚函数表数据结构
{
int (*pAdd)(void*, int); // 3. 虚函数表里面存储什么???
};
struct ClassDemo
{
struct VTable* vptr; // 1. 定义虚函数表指针 ==》 虚函数表指针类型???
int mi;
int mj;
};
struct ClassDerived
{
struct ClassDemo d;
int mk;
};
static struct VTable g_Demo_vtbl =
{
Demo_Virtual_Add
};
static struct VTable g_Derived_vtbl =
{
Derived_Virtual_Add
};
Demo* Demo_Create(int i, int j)
{
struct ClassDemo* ret = (struct ClassDemo*)malloc(sizeof(struct ClassDemo));
if( ret != NULL )
{
ret->vptr = &g_Demo_vtbl; // 4. 关联对象和虚函数表
ret->mi = i;
ret->mj = j;
}
return ret;
}
int Demo_GetI(Demo* pThis)
{
struct ClassDemo* obj = (struct ClassDemo*)pThis;
return obj->mi;
}
int Demo_GetJ(Demo* pThis)
{
struct ClassDemo* obj = (struct ClassDemo*)pThis;
return obj->mj;
}
// 6. 定义虚函数表中指针所指向的具体函数
static int Demo_Virtual_Add(Demo* pThis, int value)
{
struct ClassDemo* obj = (struct ClassDemo*)pThis;
return obj->mi + obj->mj + value;
}
// 5. 分析具体的虚函数!!!!
int Demo_Add(Demo* pThis, int value)
{
struct ClassDemo* obj = (struct ClassDemo*)pThis;
return obj->vptr->pAdd(pThis, value);
}
void Demo_Free(Demo* pThis)
{
free(pThis);
}
Derived* Derived_Create(int i, int j, int k)
{
struct ClassDerived* ret = (struct ClassDerived*)malloc(sizeof(struct ClassDerived));
if( ret != NULL )
{
ret->d.vptr = &g_Derived_vtbl;
ret->d.mi = i;
ret->d.mj = j;
ret->mk = k;
}
return ret;
}
int Derived_GetK(Derived* pThis)
{
struct ClassDerived* obj = (struct ClassDerived*)pThis;
return obj->mk;
}
static int Derived_Virtual_Add(Demo* pThis, int value)
{
struct ClassDerived* obj = (struct ClassDerived*)pThis;
return obj->mk + value;
}
int Derived_Add(Derived* pThis, int value)
{
struct ClassDerived* obj = (struct ClassDerived*)pThis;
return obj->d.vptr->pAdd(pThis, value);
}
main.c
#include "stdio.h"
#include "51-2.h"
void run(Demo* p, int v)
{
int r = Demo_Add(p, v);
printf("r = %d\n", r);
}
int main()
{
Demo* pb = Demo_Create(1, 2);
Derived* pd = Derived_Create(1, 22, 333);
printf("pb->add(3) = %d\n", Demo_Add(pb, 3));
printf("pd->add(3) = %d\n", Derived_Add(pd, 3));
run(pb, 3);
run(pd, 3);
Demo_Free(pb);
Demo_Free(pd);
return 0;
}
分析:面向对象程序最关键的地方在于必须能够表现三大特性:封装,继承,多态!封装指的是类中的敏感数据在外界是不能访问的;继承指的是可以对已经存在的类进行代码复用,并使得类之间存在父子关系;多态指的是相同的调用语句可以产生不同的调用结果。因此,如果希望用 C 语言完成面向对象的程序,那么肯定的,必须实现这三个特性;否则,最多只算得上基于对象的程序(程序中能够看到对象的影子,但是不完全具备面向对象的 3 大特性)。
本例中通过 void* 指针保证具体的结构体成员是不能在外界被访问的,以 此模拟 C++ 中 private 和 protected。因此,在头文件中定义了如下的语句:
typedef void Demo;
typedef void Derived;
Demo 和 Derived 的本质依旧是 void, 所以,用 Demo* 指针和 Derived* 指针指向具体的对象时,无法访问对象中的成员变量,这样就达到了“外界无法访问类中私有成员”的封装效果!继承的本质是父类成员与子类成员的叠加,所以在用 C 语言写面向对象程序的时候,可以直接考虑结构体成员的叠加即可。本例中的实现直接将 structClassDemo d 作为 struct ClassDerived 的第一个成员,以此表现两个自定义数据类型间的继承关系。因为 struct ClassDerived 变量的实际内存分布就是由struct ClassDemo 的成员以及 struct ClassDerived 中新定义的成员组成的,这样就直接实现了继承的本质,所以说 struct ClassDerived 继承自 structClassDemo。下一步要实现的就是多态了!多态在 C++ 中的实现本质是通过虚函数表完成的,而虚函数表是编译器自主产生和维护的数据结构。因此,接下来要解决的问题就是如何在 C 语言中自定义虚函数表?本例中认为通过结构体变量模拟 C++ 中的虚函数表是比较理想的一种选择,所以有了下面的代码:
struct VTable
{
int (*pAdd)(void*, int);
};
必须要注意的是,不能将 pAdd 指针的类型定义成了int ()(Derived, int) , 这从 C 语言的角度算不上错误,因为 Derived* 的本质就是 void* , 所以编译运行都没有问题。但是,从面向对象的角度,这里可以说是一种语义上的错误!因为 pAdd 必须可以指向父类中定义的 Add 函数版本,也可以指向子类中定义的 Add 函数版本,所以说用 Derived* 作为第一个参数表示实际对象并不合适,应该直接使用 void* 。有了类型后就可以定义实际的虚函数表了,在 C 语言中用具有文件作用域的全局变量表示实际的虚函数表是最合适的,因此有了下面的代码:
// 父类对象使用的虚函数表
static struct VTable g_Demo_vtbl =
{
Demo_Virtual_Add
};
// 子类对象使用的虚函数表
static struct VTable g_Derived_vtbl =
{
Derived_Virtual_Add
};
每个对象中都拥有一个指向虚函数表的指针,而所有父类对象都指向g_Demo_vtbl,所以所有子类对象都指向 g_Derived_vtbl。当一切就绪后,实际调用虚函数的过程就是通过虚函数表中的对应指针来完成的。可能有人仍然会有疑问,为什么传递父类对象地址和子类对象地址的时候,能够实现不同虚函数的函数的调用呢,因为在创建这个对象的时候,虚函数的地址就相应的绑定对应的对象中,既父类对象绑定了一个虚函数,子类对象也绑定了一个虚函数,而在具体调用时,当传递进去子类对象的地址时,将子类对象的类型强制转换成了父类类型,类型是发生改变了,但是内存中的东西以及排放顺序没有发生改变,所以最终就实现了多态了。