C++ 多态
1. 多态的意义
如果有几个相似而不完全相同的对象,有时人们要求在向它们发出同一个消息时,它们的反应各不相同,分别执行不同的操作。这种情况就是多态现象。
C++中所谓的多态(polymorphism)是指,由继承而产生的相关的不同的类,其对象对同一消息会作出不同的响应。
多态性是面向对象程序设计的一个重要特征,能增加程序的灵活性。可以减轻系统升级,维护,调试的工作量和复杂度。
2. 多态实现的前提
2.1 规则
赋值兼容规则是指在需要基类对象的任何地方都可以使用公有派生类的对象来替代。赋值兼容是一种默认行为,不需要任何的显示的转化步骤。赋值兼容规则中所指的替代包括以下的情况:
- 派生类的对象可以赋值给基类对象。
- 派生类的对象可以初始化基类的引用。
- 派生类对象的地址可以赋给指向基类的指针。
在替代之后,派生类对象就可以作为基类的对象使用,但只能使用从基类继承的成员。
#include <iostream>
using namespace std;
class Shape
{
public:
Shape(int x,int y)
:_x(x),_y(y){}
void draw()
{
cout<<"draw Shap ";
cout<<"start ("<<_x<<","<<_y<<") "<<endl;
}
//private:
protected:
int _x;
int _y;
};
class Circle:public Shape
{
public:
Circle(int x, int y,int r)
:Shape(x,y),_r(r){}
void draw()
{
cout<<"draw Circle ";
cout<<"start ("<<_x<<","<<_y<<") ";
cout<<"radio r = "<<_r<<endl;
}
private:
int _r;
};
int main()
{
Shape s(3,5);
s.draw();
Circle c(1,2,4);
c.draw();
s = c;
s.draw();
Shape &rs = c;
rs.draw();
Shape *ps = &c;
ps->draw();
return 0;
}
2.2 补充
父类也可以通过强转的方式转化为子类。 父类对象强转为子类对象后,访问从父类继承下来的部分是可以的,但访问子类的部分,则会发生越界的风险,越界的结果是未知的。
#include <iostream>
using namespace std;
class Shape
{
public:
Shape(int x,int y)
:_x(x),_y(y){}
void draw()
{
cout<<"draw Shap ";
cout<<"start ("<<_x<<","<<_y<<") "<<endl;
}
//private:
protected:
int _x;
int _y;
};
class Circle:public Shape
{
public:
Circle(int x, int y,int r)
:Shape(x,y),_r(r){}
void draw()
{
cout<<"draw Circle ";
cout<<"start ("<<_x<<","<<_y<<") ";
cout<<"radio r = "<<_r<<endl;
}
private:
int _r;
};
int main()
{
Shape s(3,5);
s.draw();
Circle c(1,2,4);
c.draw();
s = c;
s.draw();
Shape &rs = c;
rs.draw();
Shape *ps = &c;
ps->draw();
//c = dynamic_cast<Circle>(s); //缺少转化函数
//c.draw();
Circle * pc = static_cast<Circle*>(&s);
pc->draw();
return 0;
}
3. 多态实现的条件
3.1 多态
3.1.1 静多态
前面学习的函数重载,也是一种多态现象,通过命名倾轧在编译阶段决定,故称为静多态。
3.1.2 动多态
动多态,不是在编译器阶段决定,而是在运行阶段决定,故称为动多态。动多态形成的条件如下:
1,父类中有虚函数。
2,子类继承父类且 override(覆写)父类中的虚函数。
3,通过被子类对象赋值的父类指针或引用,调用共用接口。
3.2 虚函数
3.2.1 格式
class 类名
{
virtual 函数声明;
}
3.2.2 例子
Shape类
virtual void draw()
{
cout<<"draw Shap ";
cout<<"start ("<<_x<<","<<_y<<") "<<endl;
}
Circle类
void draw()
{
cout<<"draw Circle ";
cout<<"start ("<<_x<<","<<_y<<") ";
cout<<"radio r = "<<_r<<endl;
}
Rect类
void draw()
{
cout<<"draw Rect";
cout<<"start ("<<_x<<","<<_y<<") ";
cout<<"len = "<<_len<<" wid = "<<_wid<<endl;
}
main.cpp
int main()
{
Circle c(1,2,4);
c.draw();
Rect r(2,3,4,5);
r.draw();
Shape *ps;
int choice;
while(1) //真正的实现了动多态,在运行阶段决定。
{
scanf("%d",&choice);
switch(choice)
{
case 1:ps = &c;ps->draw();break;
case 2:ps = &r;ps->draw();break;
}
}
return 0;
}
3.2.3 小结
1,在基类中用 virual 声明成员函数为虚函数。类外实现虚函数时,不必再加 virtual。
2,在派生类中重新定义此函数称为覆写,要求函数名,返值类型,函数参数个数及类型全部匹配。并根据派生类的需要重新定义函数体。
3,当一个成员函数被声明为虚函数后,其派生类中完全相同的函数(显示的写出)也为虚函数。 可以在其前加 virtual 以示清晰。
4,定义一个指基类对象的指针,并使其指向其子类的对象,通过该指针调用虚函数,此时调用的就是指针变量指向对象的同名函数。
5,子类中的覆写的函数,可以为任意访问类型,依子类需求决定。
3.3 纯虚函数
3.3.1 格式
class 类名
{
virtual 函数声明 = 0;
}
3.3.2 例举
Shape类
virtual void draw() = 0;
Circle类
void draw()
{
cout<<"draw Circle ";
cout<<"start ("<<_x<<","<<_y<<") ";
cout<<"radio r = "<<_r<<endl;
}
3.3.3 小结
1.含有纯虚函数的类,称为抽象基类,不可实列化。即不能创建对象,存在的意义就是被继承,提供族类的公共接口,java 中称为 interface。
2.纯虚函数只有声明,没有实现,被“初始化”为 0。
3.如果一个类中声明了纯虚函数,而在派生类中没有对该函数定义,则该虚函数在派生类中仍然为纯虚函数,派生类仍然为纯虚基类。
4.含有虚函数的类,析构函数也应该声明为虚函数。在 delete 父类指针的时候,会调用子类的析构函数,实现完整析构。
3.3.4 若干限制
1)只有类的成员函数才能声明为虚函数
虚函数仅适用于有继承关系的类对象,所以普通函数不能声明为虚函数。
2)静态成员函数不能是虚函数
静态成员函数不受对象的捆绑,只有类的信息。
3)内联函数不能是虚函数
4)构造函数不能是虚函数
构造时,对象的创建尚未完成。构造完成后,才能算一个名符其实的对象。
5)析构函数可以是虚函数且通常声明为虚函数。
4. 多态实现原理
4.1 虚函数表
C++的多态是通过一张虚函数表(Virtual Table)来实现的,简称为 V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆写的问题,保证其真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。
这里我们着重看一下这张虚函数表。C++的编译器应该是保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)。 这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。
#include <iostream>
using namespace std;
typedef void(*FUNC)(void);
class Base {
public:
virtual void f() { cout << "Base::f" << endl; }
virtual void g() { cout << "Base::g" << endl; }
virtual void h() { cout << "Base::h" << endl; }
};
int main()
{
typedef void(*Fun)(void);
Base b;
int * a=NULL;
cout<<&b<<endl;
FUNC pFun = NULL;
cout<<"基类b的起始地址:"<<(int*)(&b)<<endl;
cout<<"虚函数表的第一个函数的起始地址:"<<(int**)*(int*)(&b)<<endl;
pFun = (FUNC)*((int**)*(int*)(&b));
pFun();
pFun = (FUNC)*((int**)*(int*)(&b)+1);
pFun();
pFun = (FUNC)*((int**)*(int*)(&b)+2);
pFun();
return 0;
}
5. 常见问题
5.1 为什么虚函数必须是类的成员函数
虚函数诞生的目的就是为了实现多态,在类外定义虚函数毫无实际用处。
5.2 为什么类的静态成员函数不能为虚函数
如果定义为虚函数,那么它就是动态绑定的,也就是在派生类中可以被覆盖的,这与静态成员函数的定义(在内存中只有一份拷贝;通过类名或对象引用访问静态成员)本身就是相矛盾的。
5.3 为什么构造函数不能为虚函数
因为如果构造函数为虚函数的话,它将在执行期间被构造,而执行期则需要对象已经建立,构造函数所完成的工作就是为了建立合适的对象,因此在没有构建好的对象上不可能执行多态(虚函数的目的就在于实现多态性)的工作。在继承体系中,构造的顺序就是从基类到派生类,其目的就在于确保对象能够成功地构建。构造函数同时承担着虚函数表的建立,如果它本身都是虚函数的话,如何确保 vtbl 的构建成功呢?
注意:当基类的构造函数内部有虚函数时,会出现什么情况呢?**结果是在构造函数中,虚函数机制不起作用了,调用虚函数如同调用一般的成员函数一样。**当基类的析构函数内部有虚函数时,又如何工作呢?与构造函数相同,只有“局部”的版本被调用。但是,行为相同,原因是不一样的。构造函数只能调用“局部”版本,是因为调用时还没有派生类版本的信息。析构函数则是因为派生类版本的信息已经不可靠了。我们知道,析构函数的调用顺序与构造函数相反,是从派生类的析构函数到基类的析构函数。当某个类的析构函数被调用时,其派生类的析构函数已经被调用了,相应的数据也已被丢失,如果再调用虚函数的派生类的版本,就相当于对一些不可靠的数据进行操作,这是非常危险的。因此,在析构函数中,虚函数机制也是不起作用的。