C++ 基础知识(二)——多态与虚函数
题目
C++对象模型
在介绍多态和虚函数之前,我们先来谈谈C++的对象模型(或者说编程范式)是什么,或者C++语言的复杂性在哪里。
C++不同于面向过程语言C,也不同于别的面向对象语言,如Java,Python等,是因为C++中有多种对象模型。
1.程序模型
这种模型就像C语言一样,是一种面向过程的模型。例如:
char boy[] = "Danny";
char *p_son;
...
p_son = (char *)malloc(strlen(boy) + 1);
strcpy(p_son, boy);
if(!strcpy(p_son, boy)
{
take_to_disneyland(boy);
}
2.抽象数据类型模型(abstruct data type model,ADT)
此模型相当于现实中的对象封装起来,将属性和行为联系在一起。对ADT来说,你就要确切地知道它是什么,什么类型,有具体的内存空间。比如一个老虎,是实实在在存在的对象,属于抽象数据类型模型,但是我们通常说的老虎是动物,所以设计类的时候,通常设计一个类动物(没有任何数据成员,因为不存在),然后让老虎继承。由于动物是一个抽象概念,没有实实在在存在,所以不属于抽象对象类型模型。而上面的模型则是我们后面说的面向对象模型。所以,继承在ADT里就没有多少语义了,因为所有的对象都必须实实在在存在(分配内存)。
std::string daughter;
daughter.size();
...
像代码中的对象daughter,它在内存中已经分配有空间,实实在在存在,且类型就是string。注意与面向对象模型区分开来,接下来我会讲到。
3.面向对象模型(object-oriented model)
面向对象模型就是我们所接触最多的模型了。有抽象特性,继承特性,多态特性。
Animal
{
public:
virtual void run() = 0;
};
Tiger
{
public:
virtual void run() { std::cout << "Tiger::run()" << std::endl; }
};
Dog
{
public:
virtual void run() { std::cout << "Dog::run()" << std::endl; }
};
int main()
{
Animal *animal = new Tiger;
father->run(); //打印Tiger::g()
animal = new Dog;
father->run(); //打印Dog::g()
return 0;
}
通过指针+虚函数来实现多态特性。我们并不知道该指针所指的具体类型是什么(比如Animal *animal),它可能指向Tiger对象,也可能指向Dog对象,而且动物本身并不存在。
那多态究竟是什么呢?其实就是根据你父类指针所指的对象不同,调用不同对象对该函数的执行。通俗的说,由于父类(动物)是一个抽象类,定义了该类的行为(跑),但是子类(老虎,狗)继承父类,虽然都有这样一个行为(都会跑),但是具体的方式是不一样的(狗跑,和老虎跑是不一样的),这种父类中的行为在子类中表现不一样的情况就叫多态。
4.泛型编程
就是C++中的模板。
template<typename type>
class Test
{
type x;
};
模板具体是什么,请google,我这里就不多说了。
5.总结
C++由于支持多种编程范式,或者说拥有多种对象模型,所以我们一般称C++为基于对象语言,而不是面向对象语言。C++不是像其他语言一样,只有一种编程范式,C语言面向过程,Java,Python面向对象模型。所以才造成语言的复杂性及学习的陡峭性。
至于使用一种编程范式更好呢,还是像C++这样使用多种编程范式结合编程更好呢,这个就看具体的应用场景了,没有绝对的优劣。
多态与虚函数的关系
那么多态与虚函数是什么关系呢?
其实就是多态是面向对象范式的特性,而这种特性通过C++语言的虚函数来实现(在成员函数声明前加virtual)。
而继承自该类的子类,哪怕函数声明没有virtual,但只要函数接口与父类完全一样(函数名,参数列表,返回值类型),编译器会父类继承该函数的virtual属性,该函数则为虚函数。
虚函数内部机制
1.对于类
对于定义有虚函数的类来说,编译器会为该类生成一个虚函数表(virtual table),该表中按照声明顺序保存有该类所有虚函数的地址(包括继承父类,自己没有额外实现的;自己重新实现父类的虚函数和自己新定义的)。
2.对于对象
对于含有虚函数的类创建的对象,则会在为对象申请空间时,一般编译器会在对象开头额外分配4个字节的空间,来存储该类虚函数表指针(vptr),并在构造函数中对vptr进行初始化。这样每次使用父类指针调用虚函数时,会根据你具体指向对象的不同,通过vptr来调用该对象的虚函数,从而实现了可以通过父类指针指向不同的子类对象(vptr不同),调用虚函数来产生不同的行为。
备注:对于虚函数的图文讲解,请参考陈浩的博客:C++虚函数解析 ,我觉得讲的更通俗易懂。我更多的是从文字来对此进行总结,可以看完他的该文章回头在看我的这篇文章,可能会更好一些。
虚函数使用的场景
1.普通场景
最基本的用法就是,根据你具体设计的类,同一个行为在不同的子类中会有不同的动作时,使用虚函数。
2.析构函数
析构函数通常定义为虚函数,这样就不会发生下面的行为。
class Test
{
int x;
};
class TestSon : public Test
{
public:
TestSon(int num) { x = new char[num]; }
~TestSon() { delete x; }
private:
char *x;
};
int main()
{
Test * test = new TestSon(5);
return 0;
}
当主函数执行完成后,会调用Test类的析构函数,但是你其实创建的是一个子类对象,会造成子类的开辟空间没有释放掉(因为子类的析构函数是有关紧要的,不执行的话,就无法释放new开辟的内存)。
如果把析构函数定义为虚函数时,由于父类指向的是子类对象,则会去调用子类的析构函数,不会出现问题。
问题:那么构造函数可以定义为虚函数吗?
C++中是不可以的。
根据C++的语法来说,构造函数是对创建的对象进行初始化的,而虚函数又是必须是对象创建出来之后对不同的对象执行不同的行为。对象还没有创建完成时,你此时并不知道此时这个构造过程中是构造链中父类的构造函数还是子类的构造函数,对象还没有创建,那么此时执行的多态必然是错误的。
从语言的内部原理来说,C++创建对象时,是在构造函数中初始化虚表指针(vptr)的,你在调用构造函数之前,vptr是没有值的,那怎么根据这个vptr来确定你要执行的函数的指针的,这也是矛盾的。,所以这个vptr是不是你创建对象的vptr呢,这个是未知的。
那既然C++是不可以的,那么谁可以呢,Delphi的构造函数是可以是虚函数。
参考资料
[1] 深度探索C++对象模型
[2] 陈浩的博客:C++虚函数解析地址 http://blog.youkuaiyun.com/haoel/article/details/1948051