1、什么是多态
父类指针指向子类对象
由继承而产生的相关却不同的类,其对象对同一消息会做出不同的响应。就是说:基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为多态(Polymorphism)。
多态是对象对象程序设计的一个重要特征,能增加程序的灵活性。可以减轻系统 升级、维护、调试的工作量和复杂度。
C++提供多态的目的是:可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员变量和成员函数进行“全方位”的访问,尤其是成员函数。如果没有多态,我们只能访问成员变量。
2、多态的三个必要条件
(1)必须存在继承关系;
(2)继承关系中必须有同名的虚函数,并且它们是覆盖关系(虚函数重写)。
(3)存在基类的指针或引用,通过该指针调用虚函数。(父类指针指向子类对象)
3、虚析构函数
在设计类的时候,只要考虑到该类可能被继承,就要把析构函数写成续析构函数。
然后我们来解释上面这句斜体的话,为什么一定要这么做。先看一下下面的例子。
#include <iostream>
#include <cstring>
#include <memory>
using namespace std;
class father
{
public:
father()
{
cout<<"father()....."<<endl;
m_name = new char[64];
if (NULL != m_name)
{
strcpy(m_name, "zhangbaba");
}
}
~father()
{
cout<<"~father()....."<<endl;
if (NULL != m_name)
{
delete[] m_name;
m_name = NULL;
}
}
virtual void showname()
{
cout<<"father show name:"<<m_name<<endl;
}
private:
char *m_name;
};
class son : public father
{
public:
son()
{
cout<<"son()....."<<endl;
m_name = new char[64];
if (NULL != m_name)
{
strcpy(m_name, "zhang3");
}
}
~son()
{
cout<<"~son()....."<<endl;
if (NULL != m_name)
{
delete[] m_name;
m_name = NULL;
}
}
virtual void showname()
{
cout<<"son show name:"<<m_name<<endl;
}
private:
char *m_name;
};
void test()
{
father *p = new son; //基类指针指向子类对象
p->showname();//期望发生多态
delete p;//期望按照构造顺序发生,相反顺序的析构
}
int main(void)
{
test();
cout<<"---------------------"<<endl;
return 0;
}
当我们用基类指针指向子类对象时,virtual虚函数方法实现了多态。但是当delete 基类指针的时候,并没有调用子类的析构函数。
这是因为编译器在delete的时候,只针对指针p的类型father *,做了释放。只调用了父类的析构函数。乜有析构子类。我们希望父类子类两个析构函数都被调用,以免出现内存泄露。
要做到以上的目的,其实只要调用子类的析构就可以了,子类析构调用会自动调用父类的析构,所以析构函数也一定要是虚函数,也就是父类析构函数必须定义为虚析构函数。
将父类的定义改为以下的内容
class father
{
public:
father()
{
cout<<"father()....."<<endl;
m_name = new char[64];
if (NULL != m_name)
{
strcpy(m_name, "zhangbaba");
}
}
virtual ~father()
{
cout<<"~father()....."<<endl;
if (NULL != m_name)
{
delete[] m_name;
m_name = NULL;
}
}
virtual void showname()
{
cout<<"father show name:"<<m_name<<endl;
}
private:
char *m_name;
};
执行结果达到了预期
4、虚函数表和vptr指针
下面我们来研究一下多态的原理,为什么父类指针指向子类对象时,调用virtual成员函数会发生多态。
当类中声明虚函数时,编译器会在类中生成一个虚函数表,是一个用来储存类成员函数指针的数据结构。这个虚函数表示存在于只读区。
类中的virtual 虚函数会被编译器放入虚函数表中。只要当类中存在虚函数,那么每个对象中对会有一个指向虚函数表的指针(vptr指针)。
#include <iostream>
#include <cstring>
#include <memory>
using namespace std;
class parent
{
public:
virtual void func1()
{
cout<<"parent::func1 ... "<<endl;
}
virtual void func2()
{
cout<<"parent::func2 ~~~ "<<endl;
}
public:
int pid;
};
class child : public parent
{
public:
virtual void func1()
{
cout<<"child::func1 ... "<<endl;
}
virtual void func2()
{
cout<<"child::func2 ~~~ "<<endl;
}
private:
int cid;
};
int main(void)
{
parent *pp = new parent;
pp->func1();
parent *pc = new child;
pc->func2();
return 0;
}
上面的例子,当编译器在编译的时候会分别为类parent和类child创建属于各自类的虚函数表。并d当创建父类指针指向对象的时候,会初始化vptr指针,指向该对象所属类的虚函数表。
通过指向子类对象的父类指针,调用成员函数时,会先判断是否为virtual 虚函数,如果是虚函数的话,会通过vptr访问对应的类虚函数表中所存放的成员函数指针。依次实现多态。
5、vptr指针分步初始化
当创建父类指针指向子类对象时。parent *p = new child;
*p所指向的空间,只有一个vptr,这个毋庸置疑。但是当new child时,会先调用父类的构造函数,此时vptr指向的是父类的虚函数表。当父类构造函数完成的时候,再调用子类构造函数的时候,vptr才会指向子类的虚函数表。这就叫做vptr指针分步初始化。
#include <iostream>
#include <cstring>
#include <memory>
using namespace std;
class parent
{
public:
parent(int pid)
{
cout<<"parent(int pid)..."<<endl;
func();
this->pid = pid;
}
virtual void func()
{
cout<<"parent::func ... "<<endl;
}
private:
int pid;
};
class child : public parent
{
public:
child(int cid, int pid):parent(pid)
{
cout<<"child(int cid, int pid)..."<<endl;
func();
this->cid = cid;
}
virtual void func()
{
cout<<"child::func ... "<<endl;
}
private:
int cid;
};
int main(void)
{
parent *pc = new child(10, 20);
delete pc;
return 0;
}
上面的例子,new 子类对象创建的空间地址赋值给父类指针。在父类构造函数中,调用了虚函数func,此时由于还没有完成父类构造的执行,所以当前的vptr指向的其实是父类的虚函数表,所以调用func,打印的也是父类的func。
完成父类构造后,执行子类构造,vptr也重新指向了子类的虚函数表。这个就是vptr分步初始化的表现。
但是我们常规写代码的时候不会把成员方法写进构造函数里,这样容易出现歧义,不易于走读代码。正常构造函数里面只对成员变量进行初始化。
6、类对象指针的步长
我们知道类其实也是一种数据结构,我们可以用数组的方式来实例化对象。这个时候使用多态的话,有一个大坑!!!我们先看下面的
例1
#include <iostream>
#include <cstring>
#include <memory>
using namespace std;
class parent
{
public:
parent(long a)
{
this->a = a;
}
virtual void print()
{
cout<<"parent::print ... a="<<this->a<<endl;
}
public:
long a;
};
class child : public parent
{
public:
child(long a):parent(a)
{
}
virtual void print()
{
cout<<"child::print ... a="<<this->a<<endl;
}
};
int main(void)
{
child ArrChild[3] = {child(0), child(1), child(2)};
parent *pc = NULL;
int i=0;
for(i=0, pc=&ArrChild[0]; i<3; i++, pc++)
{
pc->print();
}
return 0;
}
执行结果
我们通过父类指针pc指向数组中的子类对象,然后在for循环中,对pc++然后访问子类的方法实现多态。这样的代码写法,看似岁月静好、人畜无害。可事实真的如此吗。
当我们把上面的例1,稍微改写一下,给子类加上一个私有成员变量int b。其他的代码不变
class child : public parent
{
public:
child(long a):parent(a)
{
}
virtual void print()
{
cout<<"child::print ... a="<<this->a<<endl;
}
private:
long b;
};
执行结果:
在打印完child数组的第一个对象的方法后,就崩溃了。
因为for循环中的pc++并不代表pc所指向的数组元素,向后+1。而是代表所指向空间的大小++,通俗点说就是pc++ ==》 pc += sizeof(parent)。他会在&ArrChild[0]的地址上,向后偏移sizeof(parent)个字节。
第一次能够执行成功,纯属侥幸,因为子类没有自己的成员变量,在空间大小上完全和父类是一样的。上面黑框表示的是ArrChild数组的地址空间,pc指针的类型是parent *,pc++会按照父类parent对象的大小偏移。恰好按照一样的大小来偏移。(上图画的其实不够好,应该还有vptr的空间大小)
第二次,当我们给子类加上一个long b的成员变量的时候,空间上子类和父类的大小就不一样了。在执行的pc++的时候,就不会恰好停在元素对象的地址上,从而出现段错误。所以在使用基类指针的时候,也要额外小心,尽量不要使用数组访问的方式。
ps
Q:猜猜我上面的例子,整数数据为什么用long,而不用常见的int?
A:因为我的linux系统是64位的,而且数据结构按照8对齐。父类中一个int是4个字节,再加上vptr 相当于是void *的大小 8个字节,加起来是12字节。我们说过class跟struct是一样的,会有对齐规则,按8对齐就变成了16字节大小。子类,即使再加上一个int,也就恰好才是16字节,也不会出现崩溃。