目录
类之间的关系与现实社会非常相似。类的继承和派生是一个从抽象到具体的过程。
什么是抽象到具体的过程?以 “生物” 为例子,生物仅仅代表是一个概念。从某种程度上,它是抽象的。我们无法知道这是一个怎么样的一个东西,什么样子的特征。我们平常多说的 “生物” ,泛指有生命体的东西。没有任何实体。这在OOP里被称为抽象类,抽象类同样没有实例---它是抽象的。
以生物为父类,派生出 “人”,人的类中包含的信息就更多了。首先,人不仅继承了生物的特点,而且更加具体。人有一个头,两只手,两条腿,一双眼睛 一个鼻子等。当然人的这个类,从某种程度上还是一个抽象类,还是不够具体。因为仅仅是这些特征,你也无法分别。
接着看继承人类,可以派生出 “男人” 与 “女人”。这就更加具体。其拥有父类的所有特点,同时还派生出其他数据。当你上厕所时,你知道应该怎样去该去的地方,这个识破的过程叫RTTI( Run-Time Type Identification,运行时类型识别)。
抽象类没有实例。例如"东西"可以泛指世界万物,但是它过于抽象,无法找到"东西"的实体。具体类可以存在实例。比如 XXX牌的电动剃须刀。
指向父类对象的指针除了可以操作父类对象外,还能操作子类对象。正如"XXX牌的剃须刀"属于 剃须刀,逻辑正确。指向子类对象的指针不能操作父类对象。 比如 “剃须刀属于XXX牌剃须刀”。逻辑有问题。
如果强制将父类对象的指针转换成子类对象的指针。
Child * ch = ( Child *) ≺ // pr为父类对象,Child继承自pr
这条语句虽然可以编译通过,但是存在潜在的危险。例如,如果说:“张三长得像张三他爹”,张三和他爹都能接受。如果说:“张三他爹长得像张三”,虽然也可以,但是不招人喜欢。
扯完这么多,来看看编译器实现以上机制的技术内幕。
1.识别类和类之间的关系
在C++的继承关系中,子类具备父类所有的成员数据和成员函数。子类对象可以直接使用父类中声明为公有和保护的数据成员和成员函数。在父类中声明为私有的成员,虽然子类对象无法直接访问,但是在子类对象的内存结构中,父类私有的成员数据依然存在。C++语法规定的访问控制仅限于编译层面。
定义了两个具有继承关系的类。父类CBase中定义了数据成员m_n,构造函数,析构函数,和两个成员函数,子类中只有一个成员函数ShowNumber()和一个数据成员m_n1.根据语法规则,子类CDervie将继承父类中的成员数据和成员函数。那么,当申请了子类对象Dervie时,它在内存中如何存储,又是怎么调用父类成员的函数的呢?
反汇编代码:
因main函数内部的反汇编跟普通类的反汇编没什么差别,因此看子类的构造函数,析构函数等
CDervie的构造函数 和 析构函数:
.text:0041B7B4 ; void __cdecl CDervie::CDervie(CDervie *const this)
.text:0041B7B4 public __ZN7CDervieC1Ev
.text:0041B7B4 __ZN7CDervieC1Ev proc near ; CODE XREF: _main+1Cp
.text:0041B7B4
.text:0041B7B4 this = dword ptr -0Ch
.text:0041B7B4
.text:0041B7B4 push ebp
.text:0041B7B5 mov ebp, esp
.text:0041B7B7 sub esp, 18h
//得到this指针
.text:0041B7BA mov [ebp+this], ecx
.text:0041B7BD mov eax, [ebp+this]
//把this指针放入ecx,调用父类的构造函数
.text:0041B7C0 mov ecx, eax
.text:0041B7C2 call __ZN5CBaseC2Ev ; CBase::CBase(void)
.text:0041B7C7 leave
.text:0041B7C8 retn
.text:0041B7C8 __ZN7CDervieC1Ev endp
CDervie析构函数:
.text:0041B7CC ; void __cdecl CDervie::~CDervie(CDervie *const this)
.text:0041B7CC public __ZN7CDervieD1Ev
.text:0041B7CC __ZN7CDervieD1Ev proc near ; CODE XREF: _main+3Fp
.text:0041B7CC
.text:0041B7CC this = dword ptr -0Ch
.text:0041B7CC
.text:0041B7CC push ebp
.text:0041B7CD mov ebp, esp
.text:0041B7CF sub esp, 18h
.text:0041B7D2 mov [ebp+this], ecx
.text:0041B7D5 mov eax, [ebp+this]
//得到this后,调用父类的析构函数
.text:0041B7D8 mov ecx, eax
.text:0041B7DA call __ZN5CBaseD2Ev ; CBase::~CBase()
.text:0041B7DF leave
.text:0041B7E0 retn
.text:0041B7E0 __ZN7CDervieD1Ev endp
其实也可以发现,编译器提供了默认构造函数与析构函数。当子类中没有构造函数或析构函数,而其父类却需要构造函数与析构函数时,编译器会为该子类提供默认的构造与析构函数。
由于子类继承父类,因此子类中需要拥有父类的各成员,类似于在子类中定义了父类的对象作为数据成员引用。以下代码的类关系转换成以下代码,内存结构等价:
class CBase {...};
class CDervie{
public:
CBase m_base;//原来的父类CBase成为成员对象
int m_n1; //原来的子类派生数据
};
原来的父类CBase成为了CDervie的一个成员对象,当产生CDervie类的对象时,将会先产生m_Base,这需要调用其构造函数。当CDervie类没有构造函数时,为了能够在CDervie类对象产生时调用成员对象的构造函数,编译器同样会提供默认构造函数。
当子类对象被销毁时,其父类也同时被销毁,为了可以调用父类的析构函数,编译器为子类提供了默认的析构函数,在子类的析构函数中,析构函数的调用顺序与构造函数相反。
子类对象在内存中的数据排列为:在有初始化列表的情况下,将会优先执行初始化列表中的操作,其次是自身的构造函数。构造的顺序:先构造父类,然后按声明顺序构造成员和初始化列表,最后才是构造函数。
1.1 虚函数
学习虚函数时,分析了类中的隐藏数据成员-虚表指针。正因为有这个虚表指针,调用虚函数的方式改为查表并且间接调用,在虚表中得到函数首地址并跳转到此地址处执行代码。利用此特性即可通过父类指针访问不同的派生类。在调用父类中定义的虚函数时,根据指针所指向的对象中的虚表指针,可得到虚表指针,间接调用虚函数,即构成多态。
例子:
以"人"为基类,可以派生出不同国家的人:中国人,美国人,德国人等。这些人有着一个共同的功能-说话,但是他们实现这个功能的过程不同。例如,中国人说汉语,美国人说英语,德国人说德语等。为了让“说话”这个方法有一个通用接口,可以设立一个“人”类将其抽象化。使用指针“人”类的指针或引用调用具体对象的“说话”方法时,就形成了多态。
#include<iostream>
#include<cstdio>
using namespace std;
class CPerson
{
public:
CPerson(){}
virtual ~CPerson(){}
//这里应该用纯虚函数,后面会写到
virtual void ShowSPeak(){}
};
class CChinese:public CPerson
{
public:
CChinese(){}
virtual ~CChinese(){}
//覆盖基类虚函数
virtual void ShowSPeak()
{
printf("speak chinese\n");
}
};
class CAmerican:public CPerson
{
public:
CAmerican(){}
virtual ~CAmerican(){}
//覆盖基类虚函数
virtual void ShowSPeak()
{
printf("speak American\n");
}
};
class CGerman:public CPerson
{
public:
CGerman(){}
virtual ~CGerman(){}
//覆盖基类虚函数
virtual void ShowSPeak()
{
printf("speak CGerman\n");
}
};
void Speak( CPerson * p)
{
p -> ShowSPeak();
}
int main()
{
CChinese chinese;
CAmerican American;
CGerman German;
Speak( &chinese );
Speak( &American );
Speak( &German );
return 0;
}
我们先抽象父类(CPerson),在派生出三个具体的某国人的子类(CChinese,CAmerican,CGerman),他们有不同的说话方式 .
虽然调用的时候指针类型为父类,但由于虚表的排列顺序是按虚函数在类继承层次中首次声明的顺序依次排列的,因此,只要继承了父类,子类新定义的虚函数将会按照声明顺序紧跟其后。在调用虚函数的过程中,程序是如何通过虚表指针访问虚函数的呢?来看Speak函数的反汇编调用过程:
.text:004013B0 ; void __cdecl Speak(CPerson *p)
.text:004013B0 public __Z5SpeakP7CPerson
.text:004013B0 __Z5SpeakP7CPerson proc near ; CODE XREF: _main+37p
.text:004013B0 ; _main+43p ...
.text:004013B0
.text:004013B0 p = dword ptr 8
.text:004013B0
.text:004013B0 push ebp
.text:004013B1 mov ebp, esp
.text:004013B3 sub esp, 8
//把p放入eax
.text:004013B6 mov eax, [ebp+p]
//取虚表首地址并传给eax
.text:004013B9 mov eax, [eax]
//回顾父类CPerson的类型声明,其中第一个声明的虚函数是析构函数
//第二个声明的虚函数是ShowSpeak,所以SHowSPeak在虚表中位置第二
//eax+8即是ShowSPeak函数的首地址。
.text:004013BB add eax, 8
.text:004013BE mov eax, [eax]
//取到this指针
.text:004013C0 mov ecx, [ebp+p]
//调用ShowSpeak();
.text:004013C3 call eax
.text:004013C5 leave
.text:004013C6 retn
.text:004013C6 __Z5SpeakP7CPerson endp
1.2虚函数特性的失效
那为什么直接定义一个对象,调用的自身的虚函数呢?
当父类中有定义有虚函数时,将会产生虚表。当父类的派生类产生对象时,将会在调用子类构造函数前有限调用父类构造函数,并以子类对象的首地址作为this指针传递给父类构造函数。在父类构造函数中,会先初始化子类虚表指针为父类的虚表首地址。此时,如果在父类构造函数中调用虚函数,虽然虚表指针属于子类对象,但指向的地址却是父类的虚表首地址,这是可以知道虚表所属作用域与当前作用域相同,于是会转换成直接调用方式,
在C++规定的构造顺序,父类构造函数会在子类构造函数之前运行,在执行父类构造函数时将虚表指针修改为当前类的虚表指针,也就是父类的虚表指针,因此导致虚函数的特性失效。
虚函数会在前一篇详细展开
2.多重继承
之前所接触的派生类都只有一个父类。当子类拥有多个父类(如类C继承自类A同时也继承类B),便构成了多重继承关系。在多重继承的情况下,子类所继承的父类有多个,但其结构与单一继承相似。那么在多重继承时,父类数据成员在内存中如何存放?
下面定义了沙发基类,床基类,在一个派生类沙发床类同时继承于沙发跟床类
#include<iostream>
#include<cstdio>
using namespace std;
//定义沙发类
class Csofa
{
public:
Csofa()
{
m_ncolor = 2;
}
virtual ~Csofa()
{
printf("virtual sofa() delete");
}
virtual int GetColor()
{
return m_ncolor;
}
virtual int Sitdown()
{
return printf("sit down");
}
protected:
int m_ncolor;
};
//定义床类
class CBed
{
public:
CBed()
{
m_nLength = 4;
m_nWidth = 5;
}
virtual ~CBed()
{
printf("CBED");
}
virtual int getA()
{
return m_nLength * m_nWidth;
}
protected:
int m_nLength;
int m_nWidth;
};
class CSofaBed:public Csofa,public CBed
{
public:
CSofaBed()
{
m_nHeight = 6;
}
virtual ~CSofaBed()
{
printf("delete child");
}
protected:
int m_nHeight;
};
int main()
{
CSofaBed * cs = new CSofaBed;
delete cs;
return 0;
}
反观反汇编,用OD。调用CSofaBed的构造函数:
调用后,我们看内存分布:
001F15E8 与 001F15EF处 为虚表指针。我们在IDA中看看虚表指针指向的内容:
分别为两张虚表 。
其余画红线处,分别为成员变量。
由于有两个父类,因此子类在继承时也将它们的虚表指针一起继承了过来,也就有了两个虚表指针。可见,在多重继承中,子类虚表指针的个数取决于所继承的父类的个数(虚基类除外)。
单继承类:
- 在类对象占用的内存空间中,只保存一份虚表指针。
- 由于只有一个虚表指针,对应的只有一个虚表。
- 虚表中各项保存了类中各虚函数的首地址。
- 构造时先构造父类,在构造自身,并且只调用一次父类构造函数。
- 析构时先析构自身,再析构父类,并且只调用一次父类析构函数。
多重继承类:
- 在类对象占用的内存空间中,根据继承父类的个数保存对应的虚表指针。
- 根据所保存的虚表指针的个数,对应产生相应个数的虚表、
- 转换父类指针的时候,需要调整到对象的首地址。
- 构造时需要调用多个父类构造函数。
- 构造时先构造继承列表中的第一个父类,然后依次调用到最后一个继承的父类构造函数。
- 析构时先析构自身,然后以与构造函数相反的顺序调用所有父类的析构函数。
3.抽象类(纯虚函数的实现原理在这里补充)
既然是抽象事物,就不存在实体。如平常所说的东西,它就不能被实例化。将某一物品描述为东西,等同于没有描述。在编码过程中,抽象类的定义需要配合虚函数使用,在虚函数尾部添加"=0",这种虚函数为纯虚函数。纯虚函数是一个没有实现只有声明的函数,它的存在就是为了让类具有抽象的功能,让继承自抽象类的子类都具有虚表以及虚表指针。对于一个没有实现的虚函数,编译器是怎么处理的?
CAbBase为抽象类,child为子类。
#include<iostream>
#include<cstdio>
using namespace std;
class CAbBase
{
public:
virtual void show() = 0;
};
class child:public CAbBase
{
public:
virtual void show()
{
cout << "abstract" << endl;
}
};
int main()
{
child * ch = new child;
ch->show();
return 0;
}
先看下父类的纯虚函数内部,为了防止错误的调用,编译器在内部添加了终止程序的函数:
.text:004682C0 public ___cxa_pure_virtual
.text:004682C0 ___cxa_pure_virtual proc near ; DATA XREF: .rdata:off_479768o
.text:004682C0 ; .rdata:00479780o ...
.text:004682C0
.text:004682C0 var_1C = dword ptr -1Ch
.text:004682C0 var_18 = dword ptr -18h
.text:004682C0 var_14 = dword ptr -14h
.text:004682C0
.text:004682C0 sub esp, 1Ch
.text:004682C3 mov [esp+1Ch+var_14], 1Bh ; unsigned int
.text:004682CB mov [esp+1Ch+var_18], offset aPureVirtualMet ; "pure virtual method called\n"
.text:004682D3 mov [esp+1Ch+var_1C], 2 ; int
.text:004682DA call _write
//错误调用后,终止程序、
.text:004682DF call __ZSt9terminatev ; std::terminate(void)
.text:004682DF ___cxa_pure_virtual endp
在Release版本下,纯虚函数将会被优化掉。
4.菱形继承
菱形继承是最复杂的对象结构,菱形结构会将单一继承和多重继承进行组合。
#include<iostream>
#include<cstdio>
using namespace std;
//定义沙发类
class Base
{
protected:
int m_npri;
public:
Base(){
m_npri = 0;
}
virtual ~Base(){
}
virtual int getP()
{
return m_npri;
}
};
class Csofa:virtual public Base
{
public:
Csofa()
{
m_ncolor = 2;
}
virtual ~Csofa()
{
printf("virtual sofa() delete");
}
virtual int GetColor()
{
return m_ncolor;
}
virtual int Sitdown()
{
return printf("sit down");
}
protected:
int m_ncolor;
};
//定义床类
class CBed:virtual public Base
{
public:
CBed()
{
m_nLength = 4;
m_nWidth = 5;
}
virtual ~CBed()
{
printf("CBED");
}
virtual int getA()
{
return m_nLength * m_nWidth;
}
protected:
int m_nLength;
int m_nWidth;
};
class CSofaBed:public Csofa,public CBed
{
public:
CSofaBed()
{
m_nHeight = 6;
}
virtual ~CSofaBed()
{
printf("delete child");
}
protected:
int m_nHeight;
};
int main()
{
CSofaBed * cs = new CSofaBed;
delete cs;
return 0;
}
一共定义4个类,分别为Base,Csofa,CBed和CSofaBed。它们在继承时使用virtual的方式,即虚继承。
使用虚继承可以避免共同派生出的子类产生多义性的错误。那么,为什么要将virtual加载两个父类上而不是它们它们共同派生的子类呢??这个问题与现实世界中繁衍很相似,例如,熊猫在繁衍时避免具有血缘关系的雄性与雌性"近亲繁殖".因为繁殖出的后代出现基因重叠的问题。从而造成残缺现象。接下来先来研究下菱形CSofaBed的对象在内存中是如何存放的。
发现多了两个四字节数据。我们暂且称为vt和vt_offset.它们代表着什么。vt_offset对应的数据有两项,第一项为量一项的vt_offset的偏移值,第二项保存的是父类虚表指针相对于vt_offset的偏移值。
这三个虚表指针所指向的虚表包含了子类CSofaBed含有的虚函数,有了这些记录就可以随心所欲地将虚表指针转换成任意父类指针。