零、为什么引入多态

这部分是我写完第一部分后再加进来的,因为在我写这篇笔记的时候,其实还是不太懂为什么要用多态这个玩意儿的,虽说多态在面向对象程序设计里十分重要,而且不可或缺。既然不知道为什么要用多态,那就自己来体验一下没有多态的感觉吧!
(本部分暂略)

一、多态的概念&原理

多态”指的是同一名字的事物可以完成不同的功能。多态可以分为两种:

  • 编译时的多态:即函数、运算符的重载、对重载函数的调用,在编译时就能根据实参确定应该调用哪个函数
  • 运行时的多态:与继承、虚函数有关

虚函数,就是在声明前加了virtual关键字的成员函数。静态成员函数不能是虚函数。包含虚函数的类称为“多态”。下面是一个程序例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include<iostream>
using namespace std;

class A{
public:
int n;
//一定要加virtual
virtual void Print(){
cout<<"A"<<endl;
}
virtual void test(){}
};

class B:public A{
public:
int m;
//下面的成员函数也可以不写virtual,只要基类某函数被声明为虚函数,
//则派生类同名同参数的成员函数不加virtual,也会自动变成虚函数
virtual void Print(){
cout<<"B"<<endl;
}
};

class C:public B{
public:
virtual void Print(){
cout<<"C"<<endl;
}
};

int main()
{
A a; B b; C c;
A *pa = &a; //基态指针指向派生类对象
pa -> Print();//多态(动态联编)
pa = &b;
pa -> Print();//多态(动态联编)
pa = &c;
pa -> Print();//多态(动态联编)
return 0;
}

程序的输出结果是:

1
2
3
A
B
C

注意,编译器是不会分析程序的运行过程的,比如pa -> Print()这样的语句在编译时不能确定调用的是哪个类的成员函数,只有在运行时才能确定。因此多态的函数调用语句称为动态联编,而普通的函数调用语句称为静态联编

假如基类A的Print函数前不加virtual,那么输出结果将会是:

1
2
3
A
A
A

这是因为,我们上一篇笔记说过,编译器看到的是哪个类的指针,那么就会认为要通过它访问哪个类的成员。编译器是不会分析基类指针指向的到底是基类对象还是派生类对象。那么这是如何实现多态的呢?

多态的实现原理是,在有虚函数的类的存储空间内,都多了4个字节(在32位系统下是4个字节,如果是64位系统就是8个字节了),它们位于对象存储空间的最前面,存放着的就是虚函数表的地址。我们以上面程序的类A为例,画个图表明它的存储空间以及虚函数表。注意,类B、类C的存储空间也是类似的。

如上图,下面我们来分析一下对语句于pa -> Print()的运行细节:

  • 当pa指向对象a时,pa指向的是类A的虚函数表的地址(因为它在类A的存储空间的最前面),然后程序就会在类A的虚函数表中查出A::Print()的地址。
  • 当pa指向对象b时,pa指向的是类B的虚函数表的地址(因为它在类B的存储空间的最前面),然后程序就会在类B的虚函数表中查出B::Print()的地址。

再来分析pa -> test()的情况:

如上图,当pa指向对象b,对于语句pa -> test(),pa指向的是类B的虚函数表的地址(因为它在类B的存储空间的最前面)。类B没有test函数,因此,在类B的虚函数表里,保存的是A::test()的地址。这样,即使pa指向对象b,对于语句pa -> test(),程序也能找到A::test()的地址。

要注意,上面的例子都是在普通函数内调用虚函数的,这些语句都是多态。但是在构造函数和析构函数内部调用虚函数,就不是多态了。这是因为,根据上面的原理可知,多态是编译时不能确定调用哪个函数,但是在构造和析构函数中调用,编译时是能确定调用哪个函数的,也就不能实现多态了。

二、虚析构函数

通过基类指针删除派生类对象时,通常情况下只调用基类的析构函数。但是,我们希望的是,删除一个派生类对象时,应该先调用派生类的析构函数,然后调用基类的析构函数。

这个解决办法就是在基类的析构函数前加上virtual,那么就称为虚析构函数了,派生类的析构函数可以不加virtual声明。一般来讲,我们在类定义了虚函数,析构函数也应该定义为虚函数。例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include<iostream>
using namespace std;

class A{
public:
virtual ~A(){cout<<"A";}
};

class B:public A{
public:
~B(){cout<<"B";}
};

int main(){
A *a;
a = new B;
delete a;
return 0;
}

输出结果是BA。

需要注意的是,不允许以虚函数作为构造函数。否则的话会编译出错:[Error] constructors cannot be declared virtual [-fpermissive]

三、纯虚函数和抽象类

纯虚函数就是没有函数体的函数,而包含纯虚函数的类就叫抽象类。如下面的例子,类A就是一个抽象类:

1
2
3
4
5
class A{
public:
virtual void test() = 0;//纯虚函数,后面加“=0”
virtual void test(){}//不是纯虚函数,因为{}也是函数体
};

注意,在抽象类的成员函数内部可以调用纯虚函数,但是在构造函数或析构函数内部不能调用纯虚函数。下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include<iostream>
using namespace std;

class A{
public:
virtual void test() = 0;
void fun(){this->test();}
//A(){test();} //出错,构造函数不能调用纯虚函数!
};

class B:public A{
public:
void test(){cout<<"test!"<<endl;}
};

int main(){
B b;
b.fun();
return 0;
}

程序输出结果:test!

抽象类其实很有用。例如下面的程序片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Shape{ //基类 
public:
virtual double Length() = 0;//定义为纯虚函数
virtual double Area() = 0;
};

class Rectangle:public Shape{ //矩形
private:
double w, h;
public:
Rectangle(double _w, double _h):w(_w), h(_h){}
double Length();
double Area();
};

class Circle:public Shape{ //圆
private:
double r;
public:
Circle(double _r):r(_r){}
double Length();
double Area();
};

在这个程序中,不会有一个独立的Shape对象,因为没有一个图形的形状是什么都不是的。在我们这个程序里,要么是圆,要么是矩形,而绝不会是什么一个其他东西。因此,我们把Shape类写成抽象类,防止我们写程序的时候不小心将Shape类对象创建了,这是十分符合逻辑的。